diff --git a/config.go b/config.go index d848431..603b072 100644 --- a/config.go +++ b/config.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "sync" "github.com/samber/lo" "github.com/spf13/viper" @@ -76,30 +77,34 @@ type configCache struct { } type configBlog struct { - Path string `mapstructure:"path"` - Lang string `mapstructure:"lang"` - Title string `mapstructure:"title"` - Description string `mapstructure:"description"` - Pagination int `mapstructure:"pagination"` - DefaultSection string `mapstructure:"defaultsection"` - Sections map[string]*configSection `mapstructure:"sections"` - Taxonomies []*configTaxonomy `mapstructure:"taxonomies"` - Menus map[string]*configMenu `mapstructure:"menus"` - Photos *configPhotos `mapstructure:"photos"` - Search *configSearch `mapstructure:"search"` - BlogStats *configBlogStats `mapstructure:"blogStats"` - Blogroll *configBlogroll `mapstructure:"blogroll"` - Telegram *configTelegram `mapstructure:"telegram"` - PostAsHome bool `mapstructure:"postAsHome"` - RandomPost *configRandomPost `mapstructure:"randomPost"` - OnThisDay *configOnThisDay `mapstructure:"onThisDay"` - Comments *configComments `mapstructure:"comments"` - Map *configGeoMap `mapstructure:"map"` - Contact *configContact `mapstructure:"contact"` - Announcement *configAnnouncement `mapstructure:"announcement"` + Path string `mapstructure:"path"` + Lang string `mapstructure:"lang"` + Title string `mapstructure:"title"` + Description string `mapstructure:"description"` + Pagination int `mapstructure:"pagination"` + DefaultSection string `mapstructure:"defaultsection"` + Sections map[string]*configSection `mapstructure:"sections"` + Taxonomies []*configTaxonomy `mapstructure:"taxonomies"` + Menus map[string]*configMenu `mapstructure:"menus"` + Photos *configPhotos `mapstructure:"photos"` + Search *configSearch `mapstructure:"search"` + BlogStats *configBlogStats `mapstructure:"blogStats"` + Blogroll *configBlogroll `mapstructure:"blogroll"` + Telegram *configTelegram `mapstructure:"telegram"` + PostAsHome bool `mapstructure:"postAsHome"` + RandomPost *configRandomPost `mapstructure:"randomPost"` + OnThisDay *configOnThisDay `mapstructure:"onThisDay"` + Comments *configComments `mapstructure:"comments"` + 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 { diff --git a/editor.go b/editor.go index 0183773..8050eb3 100644 --- a/editor.go +++ b/editor.go @@ -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 diff --git a/editorState.go b/editorState.go new file mode 100644 index 0000000..0c44e35 --- /dev/null +++ b/editorState.go @@ -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) +} diff --git a/httpRouters.go b/httpRouters.go index 1c36e86..24f0aae 100644 --- a/httpRouters.go +++ b/httpRouters.go @@ -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) } } diff --git a/strings/de.yaml b/strings/de.yaml index dc006e4..b520f15 100644 --- a/strings/de.yaml +++ b/strings/de.yaml @@ -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" diff --git a/strings/default.yaml b/strings/default.yaml index e1f1e73..5001bfe 100644 --- a/strings/default.yaml +++ b/strings/default.yaml @@ -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" diff --git a/templates/assets/js/editor.js b/templates/assets/js/editor.js new file mode 100644 index 0000000..f08087c --- /dev/null +++ b/templates/assets/js/editor.js @@ -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; + }) +})() \ No newline at end of file diff --git a/templates/assets/js/formcache.js b/templates/assets/js/formcache.js deleted file mode 100644 index 24ccf46..0000000 --- a/templates/assets/js/formcache.js +++ /dev/null @@ -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) - }) - }) -})() \ No newline at end of file diff --git a/templates/assets/js/geohelper.js b/templates/assets/js/geohelper.js deleted file mode 100644 index 214c642..0000000 --- a/templates/assets/js/geohelper.js +++ /dev/null @@ -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) -})() \ No newline at end of file diff --git a/templates/assets/js/mdpreview.js b/templates/assets/js/mdpreview.js deleted file mode 100644 index 6a04f6d..0000000 --- a/templates/assets/js/mdpreview.js +++ /dev/null @@ -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) - }) - }) -})() \ No newline at end of file diff --git a/ui.go b/ui.go index 28a114a..d3c84cf 100644 --- a/ui.go +++ b/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", "") - hb.WriteElementClose("script") - } + // Script + hb.WriteElementOpen("script", "src", a.assetFileName("js/editor.js"), "defer", "") + hb.WriteElementClose("script") }, ) }