Add live markdown preview to the description editor
This commit is contained in:
parent
cf46e16df5
commit
f5ed6ec896
|
@ -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);
|
||||
// Save content on change, clear on submit
|
||||
const clearFuncs = [];
|
||||
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) {
|
||||
titleField.addEventListener('input', () => updateContentCache());
|
||||
}
|
||||
|
||||
form.addEventListener('submit', e => {
|
||||
window.localStorage.removeItem(storageKey);
|
||||
const { clear: clearTitle } = autosaveContent({
|
||||
inputEl: titleField,
|
||||
storageKey: `post-title/${window.location.host}${window.location.pathname}`,
|
||||
});
|
||||
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
|
||||
|
|
|
@ -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