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
 | 
			
		||||
of `$?` will be replaced with the correct argument number.
 | 
			
		||||
 | 
			
		||||
foo $? bar $? baz $?
 | 
			
		||||
foo ARG1 bar ARG2 baz $?
 | 
			
		||||
foo ARG1 bar ARG2 baz ARG3
 | 
			
		||||
	foo $? bar $? baz $?
 | 
			
		||||
	foo ARG1 bar ARG2 baz $?
 | 
			
		||||
	foo ARG1 bar ARG2 baz ARG3
 | 
			
		||||
*/
 | 
			
		||||
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
 | 
			
		||||
	numPlaceholders := strings.Count(sql, "$?")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -298,12 +298,12 @@ func TestProjectCSS(t *testing.T) {
 | 
			
		|||
	AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestEditorPreviewsJS(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil)
 | 
			
		||||
func TestMarkdownWorkerJS(t *testing.T) {
 | 
			
		||||
	AssertRegexMatch(t, BuildMarkdownWorkerJS(), RegexMarkdownWorkerJS, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestAPICheckUsername(t *testing.T) {
 | 
			
		||||
	AssertRegexmatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
 | 
			
		||||
	AssertRegexMatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestPublic(t *testing.T) {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -702,11 +702,11 @@ func BuildProjectCSS(color string) string {
 | 
			
		|||
	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()
 | 
			
		||||
	return Url("/assets/editorpreviews.js", nil)
 | 
			
		||||
	return Url("/assets/markdown_worker.js", nil)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var RegexS3Asset *regexp.Regexp
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,9 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "extrahead" }}
 | 
			
		||||
    <script src="{{ static "go_wasm_exec.js" }}"></script>
 | 
			
		||||
    {{ template "markdown_previews.html" . }}
 | 
			
		||||
 | 
			
		||||
    <script src="{{ static "js/base64.js" }}"></script>
 | 
			
		||||
    <script>
 | 
			
		||||
        const previewWorker = new Worker('/assets/editorpreviews.js');
 | 
			
		||||
    </script>
 | 
			
		||||
 | 
			
		||||
    <style>
 | 
			
		||||
        #editor {
 | 
			
		||||
| 
						 | 
				
			
			@ -74,42 +72,12 @@
 | 
			
		|||
                </span>
 | 
			
		||||
            {{ 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 }}
 | 
			
		||||
                <h4 class="mt3">The post you're replying to:</h4>
 | 
			
		||||
                <div class="mh-6 overflow-y-auto">
 | 
			
		||||
                    {{ template "forum_post_standalone.html" . }}
 | 
			
		||||
                </div>
 | 
			
		||||
            {{ 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>
 | 
			
		||||
        <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>
 | 
			
		||||
| 
						 | 
				
			
			@ -127,74 +95,28 @@
 | 
			
		|||
    const textField = document.querySelector('#editor');
 | 
			
		||||
    const preview = document.querySelector('#preview');
 | 
			
		||||
 | 
			
		||||
    const storagePrefix = 'post-contents';
 | 
			
		||||
 | 
			
		||||
    // 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());
 | 
			
		||||
    // Save content on change, clear on submit
 | 
			
		||||
    const clearFuncs = [];
 | 
			
		||||
    if (titleField) {
 | 
			
		||||
        titleField.addEventListener('input', () => updateContentCache());
 | 
			
		||||
        const { clear: clearTitle } = autosaveContent({
 | 
			
		||||
            inputEl: titleField,
 | 
			
		||||
            storageKey: `post-title/${window.location.host}${window.location.pathname}`,
 | 
			
		||||
        });
 | 
			
		||||
        clearFuncs.push(clearTitle);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    form.addEventListener('submit', e => {
 | 
			
		||||
        window.localStorage.removeItem(storageKey);
 | 
			
		||||
    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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -54,7 +54,7 @@
 | 
			
		|||
                            </div>
 | 
			
		||||
                        {{ end }}
 | 
			
		||||
						{{ if .CanEdit }}
 | 
			
		||||
							<div class="flex-grow-1"></div>
 | 
			
		||||
							<div class="flex-grow-1 dn db-ns"></div>
 | 
			
		||||
							<div class="root-item flex-shrink-0">
 | 
			
		||||
								<a href="{{ .EditUrl }}">Edit Project</a>
 | 
			
		||||
							</div>
 | 
			
		||||
| 
						 | 
				
			
			@ -89,8 +89,8 @@
 | 
			
		|||
            {{ end }}
 | 
			
		||||
        </div>
 | 
			
		||||
        <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 ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
 | 
			
		||||
            <a class="svgicon svgicon-nofix" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a>
 | 
			
		||||
            <a class="svgicon svgicon-nofix ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
 | 
			
		||||
        </div>
 | 
			
		||||
    </div>
 | 
			
		||||
</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.
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
let ready = false;
 | 
			
		||||
let inputData = null;
 | 
			
		||||
let wasmLoaded = false;
 | 
			
		||||
let jobs = {};
 | 
			
		||||
 | 
			
		||||
onmessage = ({ data }) => {
 | 
			
		||||
    inputData = data;
 | 
			
		||||
    const { elementID, markdown } = data;
 | 
			
		||||
    jobs[elementID] = markdown;
 | 
			
		||||
    setTimeout(doPreview, 0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -17,17 +18,21 @@ const go = new Go();
 | 
			
		|||
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
 | 
			
		||||
    .then(result => {
 | 
			
		||||
        go.run(result.instance); // don't await this; we want it to be continuously running
 | 
			
		||||
        ready = true;
 | 
			
		||||
        wasmLoaded = true;
 | 
			
		||||
        setTimeout(doPreview, 0);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
const doPreview = () => {
 | 
			
		||||
    if (!ready || inputData === null) {
 | 
			
		||||
    if (!wasmLoaded) {
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const result = parseMarkdown(inputData);
 | 
			
		||||
    inputData = null;
 | 
			
		||||
 | 
			
		||||
    postMessage(result);
 | 
			
		||||
    for (const [elementID, markdown] of Object.entries(jobs)) {
 | 
			
		||||
        const html = parseMarkdown(markdown);
 | 
			
		||||
        postMessage({
 | 
			
		||||
            elementID: elementID,
 | 
			
		||||
            html: html,
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    jobs = {};
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,13 +1,22 @@
 | 
			
		|||
{{ template "base.html" . }}
 | 
			
		||||
 | 
			
		||||
{{ define "extrahead" }}
 | 
			
		||||
	{{ template "markdown_previews.html" . }}
 | 
			
		||||
	<script src="{{ static "js/tabs.js" }}"></script>
 | 
			
		||||
	<script src="{{ static "js/image_selector.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 }}
 | 
			
		||||
 | 
			
		||||
{{ define "content" }}
 | 
			
		||||
	<div class="content-block">
 | 
			
		||||
	<div class="ph3 ph0-ns">
 | 
			
		||||
		{{ if .Editing }}
 | 
			
		||||
			<h1>Edit {{ .ProjectSettings.Name }}</h1>
 | 
			
		||||
		{{ else }}
 | 
			
		||||
| 
						 | 
				
			
			@ -28,20 +37,12 @@
 | 
			
		|||
					<div>
 | 
			
		||||
						<select name="lifecycle">
 | 
			
		||||
							<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="dead"   {{ if eq .ProjectSettings.Lifecycle "dead"   }}selected{{ end }}>Abandoned</option>
 | 
			
		||||
						</select>
 | 
			
		||||
					</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="pt-input-ns">Owners:</div>
 | 
			
		||||
					<div>
 | 
			
		||||
| 
						 | 
				
			
			@ -72,12 +73,6 @@
 | 
			
		|||
					<div class="edit-form-row">
 | 
			
		||||
						<div class="pt-input-ns">Admin settings</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>Official:</div>
 | 
			
		||||
						<div>
 | 
			
		||||
| 
						 | 
				
			
			@ -85,12 +80,27 @@
 | 
			
		|||
							<label for="official">Official HMN project</label>
 | 
			
		||||
						</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>Featured:</div>
 | 
			
		||||
						<div>
 | 
			
		||||
							<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
 | 
			
		||||
							<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>
 | 
			
		||||
				{{ end }}
 | 
			
		||||
| 
						 | 
				
			
			@ -118,10 +128,11 @@
 | 
			
		|||
				<div class="edit-form-row">
 | 
			
		||||
					<div class="pt-input-ns">Full description:</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 -}}
 | 
			
		||||
						</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 class="edit-form-row">
 | 
			
		||||
| 
						 | 
				
			
			@ -164,7 +175,7 @@
 | 
			
		|||
	<script>
 | 
			
		||||
	let csrf = JSON.parse({{ csrftokenjs .Session }});
 | 
			
		||||
 | 
			
		||||
	let projectForm = document.querySelector("#project_form")
 | 
			
		||||
	let projectForm = document.querySelector("#project_form");
 | 
			
		||||
 | 
			
		||||
	projectForm.addEventListener("invalid", function(ev) {
 | 
			
		||||
		switchToTabOfElement(document.body, ev.target);
 | 
			
		||||
| 
						 | 
				
			
			@ -174,6 +185,25 @@
 | 
			
		|||
		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 //
 | 
			
		||||
	//////////////////////
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -432,7 +432,7 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	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 {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, err)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -492,7 +492,7 @@ func ProjectEditSubmit(c *RequestContext) ResponseData {
 | 
			
		|||
 | 
			
		||||
	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 {
 | 
			
		||||
		return c.ErrorResponse(http.StatusInternalServerError, err)
 | 
			
		||||
	}
 | 
			
		||||
| 
						 | 
				
			
			@ -609,7 +609,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
 | 
			
		|||
	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
 | 
			
		||||
	if payload.LightLogo.Exists {
 | 
			
		||||
		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)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err := conn.Exec(ctx,
 | 
			
		||||
	var qb db.QueryBuilder
 | 
			
		||||
	qb.Add(
 | 
			
		||||
		`
 | 
			
		||||
		UPDATE handmade_project SET
 | 
			
		||||
			name = $2,
 | 
			
		||||
			blurb = $3,
 | 
			
		||||
			description = $4,
 | 
			
		||||
			descparsed = $5,
 | 
			
		||||
			lifecycle = $6,
 | 
			
		||||
			hidden = $7
 | 
			
		||||
		WHERE
 | 
			
		||||
			id = $1
 | 
			
		||||
			name = $?,
 | 
			
		||||
			blurb = $?,
 | 
			
		||||
			description = $?,
 | 
			
		||||
			descparsed = $?,
 | 
			
		||||
			lifecycle = $?,
 | 
			
		||||
		`,
 | 
			
		||||
		payload.ProjectID,
 | 
			
		||||
		payload.Name,
 | 
			
		||||
		payload.Blurb,
 | 
			
		||||
		payload.Description,
 | 
			
		||||
		payload.ParsedDescription,
 | 
			
		||||
		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 {
 | 
			
		||||
		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.RegexProjectCSS, ProjectCSS)
 | 
			
		||||
	anyProject.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
 | 
			
		||||
	anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) 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")
 | 
			
		||||
		return res
 | 
			
		||||
	})
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue