aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-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)
+ }
+}