diff options
| -rw-r--r-- | internal/web/handlers_design.go | 47 | ||||
| -rw-r--r-- | internal/web/handlers_pages.go | 7 | ||||
| -rw-r--r-- | internal/web/handlers_posts.go | 6 | ||||
| -rw-r--r-- | internal/web/static/dashboard.css | 8 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/page_form.html | 7 | ||||
| -rw-r--r-- | internal/web/templates/dashboard/post_form.html | 7 | ||||
| -rw-r--r-- | internal/web/templates/partials/editor.html | 44 | ||||
| -rw-r--r-- | internal/web/templates/partials/mdhelp.html | 2 | ||||
| -rw-r--r-- | internal/web/web_test.go | 16 |
9 files changed, 129 insertions, 15 deletions
diff --git a/internal/web/handlers_design.go b/internal/web/handlers_design.go index 601ba34..0db66c2 100644 --- a/internal/web/handlers_design.go +++ b/internal/web/handlers_design.go @@ -2,12 +2,14 @@ package web import ( "bytes" + "encoding/json" "errors" "io" "mime/multipart" "net/http" "path/filepath" "strconv" + "strings" "github.com/google/uuid" "github.com/gramanas/blogspace/internal/store" @@ -89,6 +91,24 @@ func (s *Server) storeUpload(r *http.Request, fh *multipart.FileHeader) (*store. return s.st.CreateImage(r.Context(), currentBlog(r).ID, name, ct, buf.Bytes()) } +// imageMarkdown is the line the editor inserts for an uploaded image. +func imageMarkdown(img *store.Image) string { + return "![" + strings.NewReplacer("]", "", "\n", " ").Replace(img.Filename) + "](/media/" + img.ID.String() + ")" +} + +// appendImageMD is the no-JavaScript path of "Insert image": the file arrives +// with the form itself and is appended to the end of the text on save. +func appendImageMD(md string, img *store.Image) string { + if img == nil { + return md + } + md = strings.TrimRight(md, "\n") + if md != "" { + md += "\n\n" + } + return md + imageMarkdown(img) + "\n" +} + func kbString(n int64) string { if n >= 1<<20 { return strconv.FormatInt(n>>20, 10) + " MB" @@ -107,24 +127,45 @@ func (s *Server) handleImages(w http.ResponseWriter, r *http.Request) { s.render(w, r, "dashboard/images.html", map[string]any{"images": images}) } +// handleImageUpload serves the Images page form and, when the client asks for +// JSON, the editor's "Insert image" script. func (s *Server) handleImageUpload(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) + wantJSON := strings.Contains(r.Header.Get("Accept"), "application/json") + fail := func(msg string) { + if wantJSON { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg}) + return + } + s.plainError(w, http.StatusBadRequest, msg) + } if err := r.ParseMultipartForm(1 << 20); err != nil { - s.plainError(w, http.StatusBadRequest, "Upload too large or malformed form.") + fail("Upload too large or malformed form.") return } img, err := s.readUpload(r, "file") if err != nil { - s.plainError(w, http.StatusBadRequest, err.Error()) + fail(err.Error()) return } if img == nil { - s.plainError(w, http.StatusBadRequest, "Choose a file first.") + fail("Choose a file first.") + return + } + if wantJSON { + writeJSON(w, http.StatusOK, map[string]string{"id": img.ID.String(), "filename": img.Filename, "markdown": imageMarkdown(img)}) return } redirectOK(w, r, "/b/"+blog.Subdomain+"/images", "Uploaded "+img.Filename+".") } +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + func (s *Server) handleImageDelete(w http.ResponseWriter, r *http.Request) { blog := currentBlog(r) id, err := uuid.Parse(r.PathValue("id")) diff --git a/internal/web/handlers_pages.go b/internal/web/handlers_pages.go index db46097..f2f82d1 100644 --- a/internal/web/handlers_pages.go +++ b/internal/web/handlers_pages.go @@ -59,14 +59,17 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { } p.Title = strings.TrimSpace(r.FormValue("title")) p.Slug = strings.TrimSpace(r.FormValue("slug")) - p.IntroMD = r.FormValue("intro") + p.IntroMD = strings.ReplaceAll(r.FormValue("intro"), "\r\n", "\n") p.ShowInNav = r.FormValue("show_in_nav") == "on" autoSlug := p.Slug == "" if autoSlug { p.Slug = slug.Make(p.Title) } + img, err := s.readUpload(r, "inline_image") var msg string switch { + case err != nil: + msg = "Image not added: " + err.Error() case p.Title == "" || len(p.Title) > 120: msg = "Title is required (max 120 characters)." case !slug.Valid(p.Slug) || reservedPageSlugs[p.Slug]: @@ -76,8 +79,8 @@ func (s *Server) handlePageSave(w http.ResponseWriter, r *http.Request) { s.renderStatus(w, r, http.StatusBadRequest, "dashboard/page_form.html", map[string]any{"page": p, "error": msg}) return } + p.IntroMD = appendImageMD(p.IntroMD, img) p.IntroHTML = markdown.Render(p.IntroMD) - var err error base := p.Slug for n := 1; ; n++ { // generated slugs get -2, -3… on collision; typed ones report the conflict p.Slug = slug.WithSuffix(base, n) diff --git a/internal/web/handlers_posts.go b/internal/web/handlers_posts.go index 8678985..136b7fe 100644 --- a/internal/web/handlers_posts.go +++ b/internal/web/handlers_posts.go @@ -86,6 +86,12 @@ func (s *Server) handlePostSave(w http.ResponseWriter, r *http.Request) { fail := func(status int, msg string) { s.renderStatus(w, r, status, "dashboard/post_form.html", map[string]any{"post": p, "pages": pages, "error": msg}) } + img, err := s.readUpload(r, "inline_image") + if err != nil { + fail(http.StatusBadRequest, "Image not added: "+err.Error()) + return + } + p.BodyMD = appendImageMD(p.BodyMD, img) pageOK := false for _, pg := range pages { if pg.ID == p.PageID { diff --git a/internal/web/static/dashboard.css b/internal/web/static/dashboard.css index e0aee73..c3d1420 100644 --- a/internal/web/static/dashboard.css +++ b/internal/web/static/dashboard.css @@ -107,11 +107,17 @@ tr.disabled td { color: #8f887c; } ul.plain { list-style: none; padding: 0; margin: 0; } ul.plain li { padding: 0.3em 0; border-bottom: 1px solid #ddd6c7; } ul.plain li:last-child { border-bottom: 0; } -details.help { margin: 0.8em 4px 1.6em 0; border: 2px dashed #1d1a17; padding: 0.5em 1em; background: #fbf8f0; } +details.help { margin: 0.8em 0 0; border: 2px dashed #1d1a17; padding: 0.5em 1em; background: #fbf8f0; } details.help summary { cursor: pointer; font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.06em; } details.help[open] summary { margin-bottom: 0.5em; } .cheat td { border: 0; padding: 0.2em 1em 0.2em 0; background: none; } +.editor-tools { margin: 0.5em 0 0; font-size: 0.9em; } +.editor-tools .upload { display: inline-block; margin: 0 0.8em 0 0; padding: 0.25em 0.7em; border: 2px solid #1d1a17; background: #fffdf8; cursor: pointer; font-size: 0.8em; } +.editor-tools .upload:hover { background: #ebe5d6; } +.editor-tools .upload input { display: inline-block; width: auto; max-width: 14em; margin: 0 0 0 0.4em; padding: 0; border: 0; background: none; font-size: 0.9em; } +.editor-tools .upload-status { font-family: "IBM Plex Mono", "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.85em; margin-right: 0.6em; } + /* ---- images -------------------------------------------------------------- */ .gallery { overflow: hidden; } .thumb { float: left; width: 200px; margin: 0 1.2em 1.6em 0; padding: 0.6em; text-align: center; } diff --git a/internal/web/templates/dashboard/page_form.html b/internal/web/templates/dashboard/page_form.html index 38aac71..2692449 100644 --- a/internal/web/templates/dashboard/page_form.html +++ b/internal/web/templates/dashboard/page_form.html @@ -2,13 +2,13 @@ {{define "content"}} <div class="card"> <h1>{{if .Data.page.ID}}Edit page{{else}}New page{{end}}</h1> - <form method="post"> + <form method="post" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> <label>Title<br><input name="title" value="{{.Data.page.Title}}" required maxlength="120"></label> <label>Address <span class="muted">(leave empty to make one from the title; e.g. <code>about</code> → {{.BlogURL}}/about)</span><br> <input name="slug" value="{{.Data.page.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> - <label>Intro text <span class="muted">(Markdown, shown above the posts; optional)</span><br> - <textarea name="intro" rows="8">{{.Data.page.IntroMD}}</textarea></label> + <label for="ed-intro">Intro text <span class="muted">(Markdown, shown above the posts; optional)</span></label> + {{template "editor" (dict "name" "intro" "value" .Data.page.IntroMD "rows" 8 "tall" false "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="show_in_nav"{{if .Data.page.ShowInNav}} checked{{end}}> Show in menu</label> <p class="actionrow"> <button type="submit">Save</button> @@ -17,5 +17,4 @@ </p> </form> </div> -{{template "mdhelp"}} {{end}} diff --git a/internal/web/templates/dashboard/post_form.html b/internal/web/templates/dashboard/post_form.html index 7e6f00f..8b4a593 100644 --- a/internal/web/templates/dashboard/post_form.html +++ b/internal/web/templates/dashboard/post_form.html @@ -2,7 +2,7 @@ {{define "content"}} <div class="card"> <h1>{{if .Data.post.ID}}Edit post{{else}}New post{{end}}</h1> - <form method="post"> + <form method="post" enctype="multipart/form-data"> <input type="hidden" name="_csrf" value="{{.CSRF}}"> <label>Title<br><input name="title" value="{{.Data.post.Title}}" required maxlength="200" autofocus></label> <div class="row"> @@ -12,8 +12,8 @@ <label>Address <span class="muted">(optional, made from the title)</span><br> <input name="slug" value="{{.Data.post.Slug}}" pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?" maxlength="80"></label> </div> - <label>Content <span class="muted">(Markdown)</span><br> - <textarea name="body" rows="24" class="editor">{{.Data.post.BodyMD}}</textarea></label> + <label for="ed-body">Content <span class="muted">(Markdown)</span></label> + {{template "editor" (dict "name" "body" "value" .Data.post.BodyMD "rows" 24 "tall" true "upload" (print "/b/" .Blog.Subdomain "/images/upload") "csrf" .CSRF)}} <label class="check"><input type="checkbox" name="published"{{if .Data.post.Published}} checked{{end}}> Visible on the blog</label> <p class="actionrow"> <button type="submit">Save</button> @@ -22,5 +22,4 @@ </p> </form> </div> -{{template "mdhelp"}} {{end}} diff --git a/internal/web/templates/partials/editor.html b/internal/web/templates/partials/editor.html new file mode 100644 index 0000000..8e0acaf --- /dev/null +++ b/internal/web/templates/partials/editor.html @@ -0,0 +1,44 @@ +{{define "editor"}}<textarea name="{{.name}}" id="ed-{{.name}}" rows="{{.rows}}"{{if .tall}} class="editor"{{end}}>{{.value}}</textarea> +<div class="editor-tools"> + <label class="upload">Insert image <input type="file" name="inline_image" id="ed-{{.name}}-file" accept="image/*"></label> + <span class="upload-status" id="ed-{{.name}}-status"></span> + <span class="muted small">…or paste / drop an image into the text.</span> + <noscript><span class="muted small">The image is added at the end of the text when you save.</span></noscript> +</div> +{{template "mdhelp"}} +<script> +(function () { + var ta = document.getElementById("ed-{{.name}}"), inp = document.getElementById("ed-{{.name}}-file"), st = document.getElementById("ed-{{.name}}-status"); + if (!window.fetch || !window.FormData || !ta || !inp) return; // old browser: the file is uploaded on save instead + function insert(text) { + var s = ta.selectionStart, e = ta.selectionEnd, pre = ta.value.slice(0, s), post = ta.value.slice(e); + var chunk = (pre && !/\n$/.test(pre) ? "\n" : "") + text + "\n"; + ta.value = pre + chunk + post; + ta.selectionStart = ta.selectionEnd = (pre + chunk).length; + ta.focus(); + } + function upload(file) { + st.textContent = "Uploading " + file.name + "…"; + var fd = new FormData(); + fd.append("_csrf", {{.csrf}}); + fd.append("file", file); + fetch({{.upload}}, { method: "POST", body: fd, credentials: "same-origin", headers: { Accept: "application/json" } }) + .then(function (r) { return r.json().then(function (j) { if (!r.ok || j.error) throw new Error(j.error || "upload failed"); return j; }); }) + .then(function (j) { insert(j.markdown); st.textContent = "Inserted " + j.filename + "."; inp.value = ""; }) + .catch(function (err) { st.textContent = "Could not upload: " + err.message; }); + } + inp.addEventListener("change", function () { if (inp.files && inp.files[0]) upload(inp.files[0]); }); + ta.addEventListener("paste", function (ev) { + var items = ev.clipboardData && ev.clipboardData.items; + if (!items) return; + for (var i = 0; i < items.length; i++) { + if (items[i].kind === "file" && /^image\//.test(items[i].type)) { ev.preventDefault(); upload(items[i].getAsFile()); return; } + } + }); + ta.addEventListener("dragover", function (ev) { ev.preventDefault(); }); + ta.addEventListener("drop", function (ev) { + var f = ev.dataTransfer && ev.dataTransfer.files && ev.dataTransfer.files[0]; + if (f && /^image\//.test(f.type)) { ev.preventDefault(); upload(f); } + }); +})(); +</script>{{end}} diff --git a/internal/web/templates/partials/mdhelp.html b/internal/web/templates/partials/mdhelp.html index 4623c07..3584025 100644 --- a/internal/web/templates/partials/mdhelp.html +++ b/internal/web/templates/partials/mdhelp.html @@ -2,7 +2,7 @@ <summary>Formatting cheat-sheet</summary> <table class="cheat"> <tr><td><code># Heading</code>, <code>## Smaller heading</code></td><td><code>**bold**</code>, <code>*italic*</code></td></tr> - <tr><td><code>[link text](https://example.org)</code></td><td><code></code> — or use <em>Insert image</em></td></tr> + <tr><td><code>[link text](https://example.org)</code></td><td><code></code> — <em>Insert image</em> writes this for you</td></tr> <tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>> quote</code>, <code>`code`</code>, <code>---</code> for a line</td></tr> </table> <p class="muted small">Blank line = new paragraph. Press Enter once for a line break.</p> diff --git a/internal/web/web_test.go b/internal/web/web_test.go index 6e53bae..6c8072e 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -7,7 +7,9 @@ import ( "strings" "testing" + "github.com/google/uuid" "github.com/gramanas/blogspace/internal/config" + "github.com/gramanas/blogspace/internal/store" ) func TestHostname(t *testing.T) { @@ -104,3 +106,17 @@ func TestSubdomainWebadminRedirect(t *testing.T) { t.Errorf("got %d %q, want 303 %q", rec.Code, rec.Header().Get("Location"), want) } } + +func TestAppendImageMD(t *testing.T) { + img := &store.Image{ID: uuid.MustParse("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), Filename: "cat].png"} + line := "" + if got := appendImageMD("", img); got != line+"\n" { + t.Errorf("empty body: %q", got) + } + if got := appendImageMD("hello\n", img); got != "hello\n\n"+line+"\n" { + t.Errorf("with body: %q", got) + } + if got := appendImageMD("hello", nil); got != "hello" { + t.Errorf("nil image should not change the body: %q", got) + } +} |
