aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorgrm <grm@eyesin.space>2026-09-12 12:26:17 +0300
committergrm <grm@eyesin.space>2026-09-12 12:26:17 +0300
commit2b450b66fee4906e746f6e04a6d07a72ff2faf64 (patch)
treeba2dbb3d9b3580113510dbf24d4ffe660cf95b26
parent336199c65d910bbd45a9c5dcbc3beae214d75b98 (diff)
downloadblogspace-2b450b66fee4906e746f6e04a6d07a72ff2faf64.tar.gz
blogspace-2b450b66fee4906e746f6e04a6d07a72ff2faf64.tar.bz2
blogspace-2b450b66fee4906e746f6e04a6d07a72ff2faf64.zip
Insert images straight from the Markdown editor
A shared editor partial adds an "Insert image" file picker plus paste and drag-and-drop on the textarea; a few lines of inline JS upload the file to the existing images endpoint (which now answers JSON when asked) and put the Markdown at the caret. Without JS the same file rides along with the form and is appended to the text on save, so nothing depends on scripting. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sd8UPWrvyYCLj97JexNw3A
-rw-r--r--internal/web/handlers_design.go47
-rw-r--r--internal/web/handlers_pages.go7
-rw-r--r--internal/web/handlers_posts.go6
-rw-r--r--internal/web/static/dashboard.css8
-rw-r--r--internal/web/templates/dashboard/page_form.html7
-rw-r--r--internal/web/templates/dashboard/post_form.html7
-rw-r--r--internal/web/templates/partials/editor.html44
-rw-r--r--internal/web/templates/partials/mdhelp.html2
-rw-r--r--internal/web/web_test.go16
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>![description](/media/…)</code> — or use <em>Insert image</em></td></tr>
+ <tr><td><code>[link text](https://example.org)</code></td><td><code>![description](/media/…)</code> — <em>Insert image</em> writes this for you</td></tr>
<tr><td><code>- list item</code> / <code>1. numbered</code></td><td><code>&gt; 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 := "![cat.png](/media/6ba7b810-9dad-11d1-80b4-00c04fd430c8)"
+ 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)
+ }
+}