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
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/samber/lo"
|
||||
"github.com/spf13/viper"
|
||||
|
@ -97,9 +98,13 @@ type configBlog struct {
|
|||
Map *configGeoMap `mapstructure:"map"`
|
||||
Contact *configContact `mapstructure:"contact"`
|
||||
Announcement *configAnnouncement `mapstructure:"announcement"`
|
||||
// Configs read from database
|
||||
hideOldContentWarning bool
|
||||
hideShareButton bool
|
||||
hideTranslateButton bool
|
||||
// Editor state WebSockets
|
||||
esws sync.Map
|
||||
esm sync.Mutex
|
||||
}
|
||||
|
||||
type configSection struct {
|
||||
|
|
|
@ -33,7 +33,7 @@ func (a *goBlog) serveEditorPreview(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
c.SetReadLimit(1 << 20) // 1MB
|
||||
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()
|
||||
for {
|
||||
// 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"+paginationPath, a.serveDeleted)
|
||||
r.HandleFunc("/preview", a.serveEditorPreview)
|
||||
r.HandleFunc("/sync", a.serveEditorStateSync)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -20,6 +20,7 @@ drafts: "Entwürfe"
|
|||
draftsdesc: "Posts mit dem Status `draft`."
|
||||
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."
|
||||
editorusetemplate: "Benutze Vorlage"
|
||||
emailopt: "E-Mail (optional)"
|
||||
fileuses: "Datei-Verwendungen"
|
||||
general: "Allgemein"
|
||||
|
|
|
@ -23,6 +23,7 @@ drafts: "Drafts"
|
|||
draftsdesc: "Posts with status `draft`."
|
||||
editor: "Editor"
|
||||
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)"
|
||||
feed: "Feed"
|
||||
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)
|
||||
})
|
||||
})
|
||||
})()
|
19
ui.go
19
ui.go
|
@ -1378,15 +1378,21 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
|
|||
_ = a.renderMarkdownToWriter(hb, a.editorPostDesc(rd.Blog), false)
|
||||
hb.WriteElementOpen("form", "method", "post", "class", "fw p")
|
||||
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(
|
||||
"textarea",
|
||||
"id", "editor-create",
|
||||
"name", "content",
|
||||
"class", "monospace h400p formcache mdpreview",
|
||||
"class", "monospace h400p",
|
||||
"id", "create-input",
|
||||
"data-preview", "post-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.WriteElementOpen("div", "id", "post-preview", "class", "hide")
|
||||
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(
|
||||
"textarea",
|
||||
"id", "editor-update",
|
||||
"name", "content",
|
||||
"class", "monospace h400p mdpreview",
|
||||
"class", "monospace h400p",
|
||||
"data-preview", "update-preview",
|
||||
"data-previewws", rd.Blog.getRelativePath("/editor/preview"),
|
||||
)
|
||||
|
@ -1484,11 +1491,9 @@ func (a *goBlog) renderEditor(hb *htmlbuilder.HtmlBuilder, rd *renderData) {
|
|||
|
||||
hb.WriteElementClose("main")
|
||||
|
||||
// Scripts
|
||||
for _, script := range []string{"js/mdpreview.js", "js/geohelper.js", "js/formcache.js"} {
|
||||
hb.WriteElementOpen("script", "src", a.assetFileName(script), "defer", "")
|
||||
// Script
|
||||
hb.WriteElementOpen("script", "src", a.assetFileName("js/editor.js"), "defer", "")
|
||||
hb.WriteElementClose("script")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue