mirror of https://github.com/jlelse/GoBlog
Sync editor state per blog using websockets and cache in database
This commit is contained in:
parent
f9d568acc8
commit
903d5a265a
47
config.go
47
config.go
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
|
@ -76,30 +77,34 @@ type configCache struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type configBlog struct {
|
type configBlog struct {
|
||||||
Path string `mapstructure:"path"`
|
Path string `mapstructure:"path"`
|
||||||
Lang string `mapstructure:"lang"`
|
Lang string `mapstructure:"lang"`
|
||||||
Title string `mapstructure:"title"`
|
Title string `mapstructure:"title"`
|
||||||
Description string `mapstructure:"description"`
|
Description string `mapstructure:"description"`
|
||||||
Pagination int `mapstructure:"pagination"`
|
Pagination int `mapstructure:"pagination"`
|
||||||
DefaultSection string `mapstructure:"defaultsection"`
|
DefaultSection string `mapstructure:"defaultsection"`
|
||||||
Sections map[string]*configSection `mapstructure:"sections"`
|
Sections map[string]*configSection `mapstructure:"sections"`
|
||||||
Taxonomies []*configTaxonomy `mapstructure:"taxonomies"`
|
Taxonomies []*configTaxonomy `mapstructure:"taxonomies"`
|
||||||
Menus map[string]*configMenu `mapstructure:"menus"`
|
Menus map[string]*configMenu `mapstructure:"menus"`
|
||||||
Photos *configPhotos `mapstructure:"photos"`
|
Photos *configPhotos `mapstructure:"photos"`
|
||||||
Search *configSearch `mapstructure:"search"`
|
Search *configSearch `mapstructure:"search"`
|
||||||
BlogStats *configBlogStats `mapstructure:"blogStats"`
|
BlogStats *configBlogStats `mapstructure:"blogStats"`
|
||||||
Blogroll *configBlogroll `mapstructure:"blogroll"`
|
Blogroll *configBlogroll `mapstructure:"blogroll"`
|
||||||
Telegram *configTelegram `mapstructure:"telegram"`
|
Telegram *configTelegram `mapstructure:"telegram"`
|
||||||
PostAsHome bool `mapstructure:"postAsHome"`
|
PostAsHome bool `mapstructure:"postAsHome"`
|
||||||
RandomPost *configRandomPost `mapstructure:"randomPost"`
|
RandomPost *configRandomPost `mapstructure:"randomPost"`
|
||||||
OnThisDay *configOnThisDay `mapstructure:"onThisDay"`
|
OnThisDay *configOnThisDay `mapstructure:"onThisDay"`
|
||||||
Comments *configComments `mapstructure:"comments"`
|
Comments *configComments `mapstructure:"comments"`
|
||||||
Map *configGeoMap `mapstructure:"map"`
|
Map *configGeoMap `mapstructure:"map"`
|
||||||
Contact *configContact `mapstructure:"contact"`
|
Contact *configContact `mapstructure:"contact"`
|
||||||
Announcement *configAnnouncement `mapstructure:"announcement"`
|
Announcement *configAnnouncement `mapstructure:"announcement"`
|
||||||
|
// Configs read from database
|
||||||
hideOldContentWarning bool
|
hideOldContentWarning bool
|
||||||
hideShareButton bool
|
hideShareButton bool
|
||||||
hideTranslateButton bool
|
hideTranslateButton bool
|
||||||
|
// Editor state WebSockets
|
||||||
|
esws sync.Map
|
||||||
|
esm sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
type configSection struct {
|
type configSection struct {
|
||||||
|
|
|
@ -33,7 +33,7 @@ func (a *goBlog) serveEditorPreview(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
c.SetReadLimit(1 << 20) // 1MB
|
c.SetReadLimit(1 << 20) // 1MB
|
||||||
defer c.Close(ws.StatusNormalClosure, "")
|
defer c.Close(ws.StatusNormalClosure, "")
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), time.Minute*60)
|
ctx, cancel := context.WithTimeout(r.Context(), time.Hour*6)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
for {
|
for {
|
||||||
// Retrieve content
|
// Retrieve content
|
||||||
|
|
|
@ -0,0 +1,103 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
ws "nhooyr.io/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (a *goBlog) serveEditorStateSync(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Get blog
|
||||||
|
blog, bc := a.getBlog(r)
|
||||||
|
// Open websocket connection
|
||||||
|
c, err := ws.Accept(w, r, &ws.AcceptOptions{CompressionMode: ws.CompressionContextTakeover})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.SetReadLimit(1 << 20) // 1MB
|
||||||
|
defer c.Close(ws.StatusNormalClosure, "")
|
||||||
|
// Store connection to be able to send updates
|
||||||
|
connectionId := uuid.NewString()
|
||||||
|
bc.esws.Store(connectionId, c)
|
||||||
|
defer bc.esws.Delete(connectionId)
|
||||||
|
// Set cancel context
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Hour*6)
|
||||||
|
defer cancel()
|
||||||
|
// Send initial content
|
||||||
|
if r.URL.Query().Get("initial") == "1" {
|
||||||
|
initialState, err := a.getEditorStateFromDatabase(ctx, blog)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if initialState != nil {
|
||||||
|
w, err := c.Writer(ctx, ws.MessageText)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, err = w.Write(initialState)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = w.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Listen for new messages
|
||||||
|
for {
|
||||||
|
// Retrieve content
|
||||||
|
mt, message, err := c.Reader(ctx)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if mt != ws.MessageText {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messageBytes, err := io.ReadAll(message)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Save editor state
|
||||||
|
bc.esm.Lock()
|
||||||
|
a.updateEditorStateInDatabase(ctx, blog, messageBytes)
|
||||||
|
bc.esm.Unlock()
|
||||||
|
// Send editor state to other connections
|
||||||
|
a.sendNewEditorStateToAllConnections(ctx, bc, connectionId, messageBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *goBlog) sendNewEditorStateToAllConnections(ctx context.Context, bc *configBlog, origin string, state []byte) {
|
||||||
|
bc.esws.Range(func(key, value any) bool {
|
||||||
|
if key == origin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
c, ok := value.(*ws.Conn)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
w, err := c.Writer(ctx, ws.MessageText)
|
||||||
|
if err != nil {
|
||||||
|
bc.esws.Delete(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
defer w.Close()
|
||||||
|
_, err = w.Write(state)
|
||||||
|
if err != nil {
|
||||||
|
bc.esws.Delete(key)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorStateCacheKey = "editorstate_"
|
||||||
|
|
||||||
|
func (a *goBlog) updateEditorStateInDatabase(ctx context.Context, blog string, state []byte) {
|
||||||
|
_ = a.db.cachePersistentlyContext(ctx, editorStateCacheKey+blog, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *goBlog) getEditorStateFromDatabase(ctx context.Context, blog string) ([]byte, error) {
|
||||||
|
return a.db.retrievePersistentCacheContext(ctx, editorStateCacheKey+blog)
|
||||||
|
}
|
|
@ -353,6 +353,7 @@ func (a *goBlog) blogEditorRouter(_ *configBlog) func(r chi.Router) {
|
||||||
r.Get("/deleted"+feedPath, a.serveDeleted)
|
r.Get("/deleted"+feedPath, a.serveDeleted)
|
||||||
r.Get("/deleted"+paginationPath, a.serveDeleted)
|
r.Get("/deleted"+paginationPath, a.serveDeleted)
|
||||||
r.HandleFunc("/preview", a.serveEditorPreview)
|
r.HandleFunc("/preview", a.serveEditorPreview)
|
||||||
|
r.HandleFunc("/sync", a.serveEditorStateSync)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ drafts: "Entwürfe"
|
||||||
draftsdesc: "Posts mit dem Status `draft`."
|
draftsdesc: "Posts mit dem Status `draft`."
|
||||||
editor: "Editor"
|
editor: "Editor"
|
||||||
editorpostdesc: "💡 Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s` und `%s`: %s und %s."
|
editorpostdesc: "💡 Leere Parameter werden automatisch entfernt. Mehr mögliche Parameter: %s. Mögliche Zustände für `%s` und `%s`: %s und %s."
|
||||||
|
editorusetemplate: "Benutze Vorlage"
|
||||||
emailopt: "E-Mail (optional)"
|
emailopt: "E-Mail (optional)"
|
||||||
fileuses: "Datei-Verwendungen"
|
fileuses: "Datei-Verwendungen"
|
||||||
general: "Allgemein"
|
general: "Allgemein"
|
||||||
|
|
|
@ -23,6 +23,7 @@ drafts: "Drafts"
|
||||||
draftsdesc: "Posts with status `draft`."
|
draftsdesc: "Posts with status `draft`."
|
||||||
editor: "Editor"
|
editor: "Editor"
|
||||||
editorpostdesc: "💡 Empty parameters are removed automatically. More possible parameters: %s. Possible states for `%s` and `%s`: %s and %s."
|
editorpostdesc: "💡 Empty parameters are removed automatically. More possible parameters: %s. Possible states for `%s` and `%s`: %s and %s."
|
||||||
|
editorusetemplate: "Use template"
|
||||||
emailopt: "Email (optional)"
|
emailopt: "Email (optional)"
|
||||||
feed: "Feed"
|
feed: "Feed"
|
||||||
fileuses: "file uses"
|
fileuses: "file uses"
|
||||||
|
|
|
@ -0,0 +1,124 @@
|
||||||
|
(function () {
|
||||||
|
// Preview
|
||||||
|
function openPreviewWS(element) {
|
||||||
|
// Get preview container
|
||||||
|
let previewContainer = document.getElementById(element.dataset.preview)
|
||||||
|
if (!previewContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Get websocket path
|
||||||
|
let wsUrl = element.dataset.previewws
|
||||||
|
if (!wsUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Create and open websocket
|
||||||
|
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl)
|
||||||
|
ws.onopen = function () {
|
||||||
|
console.log("Preview-Websocket opened")
|
||||||
|
previewContainer.classList.add('preview')
|
||||||
|
previewContainer.classList.remove('hide')
|
||||||
|
if (ws) {
|
||||||
|
ws.send(element.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ws.onclose = function () {
|
||||||
|
console.log("Preview-Websocket closed, try to reopen in 1 second")
|
||||||
|
previewContainer.classList.add('hide')
|
||||||
|
previewContainer.classList.remove('preview')
|
||||||
|
previewContainer.innerHTML = ''
|
||||||
|
ws = null
|
||||||
|
setTimeout(function () { openPreviewWS(element) }, 1000);
|
||||||
|
}
|
||||||
|
ws.onmessage = function (evt) {
|
||||||
|
// Set preview HTML
|
||||||
|
previewContainer.innerHTML = evt.data
|
||||||
|
}
|
||||||
|
ws.onerror = function (evt) {
|
||||||
|
console.log("Preview-Websocket error: " + evt.data)
|
||||||
|
}
|
||||||
|
// Add listener
|
||||||
|
let timeout = null
|
||||||
|
element.addEventListener('input', function () {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(function () {
|
||||||
|
if (ws) {
|
||||||
|
ws.send(element.value)
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Array.from(document.querySelectorAll('#editor-create, #editor-update')).forEach(element => openPreviewWS(element))
|
||||||
|
|
||||||
|
// Sync state
|
||||||
|
function openSyncStateWS(element, initial) {
|
||||||
|
// Get websocket path
|
||||||
|
let wsUrl = element.dataset.syncws
|
||||||
|
if (!wsUrl) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Create and open websocket
|
||||||
|
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl + '?initial=' + initial)
|
||||||
|
ws.onopen = function () {
|
||||||
|
console.log("Sync-Websocket opened")
|
||||||
|
}
|
||||||
|
ws.onclose = function () {
|
||||||
|
console.log("Sync-Websocket closed, try to reopen in 1 second")
|
||||||
|
ws = null
|
||||||
|
setTimeout(function () { openSyncStateWS(element, "0") }, 1000);
|
||||||
|
}
|
||||||
|
ws.onmessage = function (evt) {
|
||||||
|
element.value = evt.data
|
||||||
|
}
|
||||||
|
ws.onerror = function (evt) {
|
||||||
|
console.log("Sync-Websocket error: " + evt.data)
|
||||||
|
}
|
||||||
|
// Add listener
|
||||||
|
let timeout = null
|
||||||
|
element.addEventListener('input', function () {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(function () {
|
||||||
|
if (ws) {
|
||||||
|
ws.send(element.value)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
// Clear on submit
|
||||||
|
element.form.addEventListener('submit', function () {
|
||||||
|
if (ws) {
|
||||||
|
ws.send('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Array.from(document.querySelectorAll('#editor-create')).forEach(element => openSyncStateWS(element, "1"))
|
||||||
|
|
||||||
|
// Geo button
|
||||||
|
let geoBtn = document.querySelector('#geobtn')
|
||||||
|
geoBtn.addEventListener('click', function () {
|
||||||
|
let status = document.querySelector('#geostatus')
|
||||||
|
status.classList.add('hide')
|
||||||
|
status.value = ''
|
||||||
|
|
||||||
|
function success(position) {
|
||||||
|
let latitude = position.coords.latitude
|
||||||
|
let longitude = position.coords.longitude
|
||||||
|
status.value = `geo:${latitude},${longitude}`
|
||||||
|
status.classList.remove('hide')
|
||||||
|
}
|
||||||
|
|
||||||
|
function error() {
|
||||||
|
alert(geoBtn.dataset.failed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.geolocation) {
|
||||||
|
navigator.geolocation.getCurrentPosition(success, error)
|
||||||
|
} else {
|
||||||
|
alert(geoBtn.dataset.notsupported)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Template button
|
||||||
|
document.querySelector('#templatebtn').addEventListener('click', function () {
|
||||||
|
let area = document.querySelector('#editor-create')
|
||||||
|
area.value = area.dataset.template;
|
||||||
|
})
|
||||||
|
})()
|
|
@ -1,19 +0,0 @@
|
||||||
(function () {
|
|
||||||
const fc = 'formcache'
|
|
||||||
Array.from(document.querySelectorAll('form .' + fc)).forEach(element => {
|
|
||||||
let elementName = fc + '-' + location.pathname + '#' + element.id
|
|
||||||
// Load from cache
|
|
||||||
let cached = localStorage.getItem(elementName)
|
|
||||||
if (cached != null) {
|
|
||||||
element.value = cached
|
|
||||||
}
|
|
||||||
// Auto save to cache
|
|
||||||
element.addEventListener('input', function () {
|
|
||||||
localStorage.setItem(elementName, element.value)
|
|
||||||
})
|
|
||||||
// Clear on submit
|
|
||||||
element.form.addEventListener('submit', function () {
|
|
||||||
localStorage.removeItem(elementName)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})()
|
|
|
@ -1,26 +0,0 @@
|
||||||
(function () {
|
|
||||||
let geoBtn = document.querySelector('#geobtn')
|
|
||||||
function geo() {
|
|
||||||
let status = document.querySelector('#geostatus')
|
|
||||||
status.classList.add('hide')
|
|
||||||
status.value = ''
|
|
||||||
|
|
||||||
function success(position) {
|
|
||||||
let latitude = position.coords.latitude
|
|
||||||
let longitude = position.coords.longitude
|
|
||||||
status.value = `geo:${latitude},${longitude}`
|
|
||||||
status.classList.remove('hide')
|
|
||||||
}
|
|
||||||
|
|
||||||
function error() {
|
|
||||||
alert(geoBtn.dataset.failed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (navigator.geolocation) {
|
|
||||||
navigator.geolocation.getCurrentPosition(success, error)
|
|
||||||
} else {
|
|
||||||
alert(geoBtn.dataset.notsupported)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
geoBtn.addEventListener('click', geo)
|
|
||||||
})()
|
|
|
@ -1,48 +0,0 @@
|
||||||
(function () {
|
|
||||||
Array.from(document.querySelectorAll('.mdpreview')).forEach(element => {
|
|
||||||
// Get preview container
|
|
||||||
let previewContainer = document.getElementById(element.dataset.preview)
|
|
||||||
if (!previewContainer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Get websocket path
|
|
||||||
let wsUrl = element.dataset.previewws
|
|
||||||
if (!wsUrl) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Create and open websocket
|
|
||||||
let ws = new WebSocket(((window.location.protocol === "https:") ? "wss://" : "ws://") + window.location.host + wsUrl)
|
|
||||||
ws.onopen = function () {
|
|
||||||
console.log("Preview-Websocket opened")
|
|
||||||
previewContainer.classList.add('preview')
|
|
||||||
previewContainer.classList.remove('hide')
|
|
||||||
if (ws) {
|
|
||||||
ws.send(element.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.onclose = function () {
|
|
||||||
console.log("Preview-Websocket closed")
|
|
||||||
previewContainer.classList.add('hide')
|
|
||||||
previewContainer.classList.remove('preview')
|
|
||||||
previewContainer.innerHTML = ''
|
|
||||||
ws = null
|
|
||||||
}
|
|
||||||
ws.onmessage = function (evt) {
|
|
||||||
// Set preview HTML
|
|
||||||
previewContainer.innerHTML = evt.data
|
|
||||||
}
|
|
||||||
ws.onerror = function (evt) {
|
|
||||||
console.log("Preview-Websocket error: " + evt.data)
|
|
||||||
}
|
|
||||||
// Add listener
|
|
||||||
let timeout = null
|
|
||||||
element.addEventListener('input', function () {
|
|
||||||
clearTimeout(timeout)
|
|
||||||
timeout = setTimeout(function () {
|
|
||||||
if (ws) {
|
|
||||||
ws.send(element.value)
|
|
||||||
}
|
|
||||||
}, 500)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})()
|
|
21
ui.go
21
ui.go
|
@ -1378,15 +1378,21 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
|
||||||
_ = a.renderMarkdownToWriter(hb, a.editorPostDesc(rd.Blog), false)
|
_ = a.renderMarkdownToWriter(hb, a.editorPostDesc(rd.Blog), false)
|
||||||
hb.WriteElementOpen("form", "method", "post", "class", "fw p")
|
hb.WriteElementOpen("form", "method", "post", "class", "fw p")
|
||||||
hb.WriteElementOpen("input", "type", "hidden", "name", "h", "value", "entry")
|
hb.WriteElementOpen("input", "type", "hidden", "name", "h", "value", "entry")
|
||||||
|
hb.WriteElementOpen(
|
||||||
|
"input", "id", "templatebtn", "type", "button",
|
||||||
|
"value", a.ts.GetTemplateStringVariant(rd.Blog.Lang, "editorusetemplate"),
|
||||||
|
)
|
||||||
hb.WriteElementOpen(
|
hb.WriteElementOpen(
|
||||||
"textarea",
|
"textarea",
|
||||||
|
"id", "editor-create",
|
||||||
"name", "content",
|
"name", "content",
|
||||||
"class", "monospace h400p formcache mdpreview",
|
"class", "monospace h400p",
|
||||||
"id", "create-input",
|
"id", "create-input",
|
||||||
"data-preview", "post-preview",
|
"data-preview", "post-preview",
|
||||||
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
|
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
|
||||||
|
"data-syncws", rd.Blog.getRelativePath("/editor/sync"),
|
||||||
|
"data-template", a.editorPostTemplate(rd.BlogString, rd.Blog),
|
||||||
)
|
)
|
||||||
hb.WriteEscaped(a.editorPostTemplate(rd.BlogString, rd.Blog))
|
|
||||||
hb.WriteElementClose("textarea")
|
hb.WriteElementClose("textarea")
|
||||||
hb.WriteElementOpen("div", "id", "post-preview", "class", "hide")
|
hb.WriteElementOpen("div", "id", "post-preview", "class", "hide")
|
||||||
hb.WriteElementClose("div")
|
hb.WriteElementClose("div")
|
||||||
|
@ -1403,8 +1409,9 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
|
||||||
hb.WriteElementOpen("input", "type", "hidden", "name", "url", "value", edrd.updatePostUrl)
|
hb.WriteElementOpen("input", "type", "hidden", "name", "url", "value", edrd.updatePostUrl)
|
||||||
hb.WriteElementOpen(
|
hb.WriteElementOpen(
|
||||||
"textarea",
|
"textarea",
|
||||||
|
"id", "editor-update",
|
||||||
"name", "content",
|
"name", "content",
|
||||||
"class", "monospace h400p mdpreview",
|
"class", "monospace h400p",
|
||||||
"data-preview", "update-preview",
|
"data-preview", "update-preview",
|
||||||
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
|
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
|
||||||
)
|
)
|
||||||
|
@ -1484,11 +1491,9 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
|
||||||
|
|
||||||
hb.WriteElementClose("main")
|
hb.WriteElementClose("main")
|
||||||
|
|
||||||
// Scripts
|
// Script
|
||||||
for _, script := range []string{"js/mdpreview.js", "js/geohelper.js", "js/formcache.js"} {
|
hb.WriteElementOpen("script", "src", a.assetFileName("js/editor.js"), "defer", "")
|
||||||
hb.WriteElementOpen("script", "src", a.assetFileName(script), "defer", "")
|
hb.WriteElementClose("script")
|
||||||
hb.WriteElementClose("script")
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue