From ab58398e68759dac4d29b110be8c851915b00b6c Mon Sep 17 00:00:00 2001 From: codegirl-007 Date: Fri, 28 Aug 2026 05:31:12 -0700 Subject: [PATCH] Add post image upload interface --- internal/web/posts_test.go | 90 ++++++++++ internal/web/server.go | 11 ++ internal/web/server_test.go | 5 + static/app.css | 245 ++++++++++++++++++++++++++ static/app.js | 194 ++++++++++++++++++++ templates/partials/_image_picker.html | 85 +++++++++ templates/partials/_post.html | 11 +- templates/question.html | 1 + templates/submit.html | 3 +- 9 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 templates/partials/_image_picker.html diff --git a/internal/web/posts_test.go b/internal/web/posts_test.go index b61b1fb..bad9e80 100644 --- a/internal/web/posts_test.go +++ b/internal/web/posts_test.go @@ -413,6 +413,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { Body: "Water under the cabinet.", City: "Oakland", PostDate: pacific.Today(), + Images: []store.PostImage{{ + ID: "root-photo", ObjectKey: "post-images/root-photo.jpg", + PublicURL: "https://cdn.example/root-photo.jpg", + Description: "Water pooling below the shutoff valve", + Width: 1200, Height: 900, + }}, } if err := mem.CreatePost(context.Background(), root); err != nil { t.Fatal(err) @@ -421,6 +427,11 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { ParentID: &root.ID, AuthorID: homeowner.ID, Body: "The model number is 123.", + Images: []store.PostImage{{ + ID: "reply-photo", ObjectKey: "post-images/reply-photo.png", + PublicURL: "https://cdn.example/reply-photo.png", + Width: 900, Height: 1200, + }}, } if err := mem.CreatePost(context.Background(), homeownerReply); err != nil { t.Fatal(err) @@ -429,6 +440,12 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { ParentID: &homeownerReply.ID, AuthorID: admin.ID, Body: "Replace the cartridge.", + Images: []store.PostImage{{ + ID: "admin-photo", ObjectKey: "post-images/admin-photo.webp", + PublicURL: "https://cdn.example/admin-photo.webp", + Description: "Replacement cartridge orientation", + Width: 1000, Height: 1000, + }}, } if err := mem.CreatePost(context.Background(), adminReply); err != nil { t.Fatal(err) @@ -460,6 +477,19 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { `action="/posts"`, `data-submit-once`, `data-submit-button`, + `enctype="multipart/form-data"`, + `data-image-picker`, + `accept="image/jpeg,image/png,image/webp"`, + `aria-live="polite"`, + `name="existing_image_id" value="root-photo"`, + `name="existing_image_id" value="reply-photo"`, + `src="https://cdn.example/root-photo.jpg"`, + `alt="Water pooling below the shutoff valve"`, + `src="https://cdn.example/reply-photo.png"`, + `alt="Photo attached to this post"`, + `src="https://cdn.example/admin-photo.webp"`, + `loading="lazy" decoding="async"`, + `
Replacement cartridge orientation
`, `action="/posts/` + root.ID + `/edit"`, `action="/posts/` + homeownerReply.ID + `/edit"`, `href="/questions/` + root.ID + `#post-` + root.ID + `"`, @@ -475,6 +505,9 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { if got := strings.Count(body, ">Permalink"); got != 3 { t.Fatalf("question page rendered %d permalinks, want 3: %s", got, body) } + if got := strings.Count(body, `data-image-picker`); got != 5 { + t.Fatalf("question page rendered %d image pickers, want 5: %s", got, body) + } if strings.Contains(body, `action="/posts/`+adminReply.ID+`/edit"`) { t.Fatalf("homeowner can edit admin reply: %s", body) } @@ -490,6 +523,63 @@ func TestQuestionPageRendersNestedPostControls(t *testing.T) { strings.Contains(rec.Body.String(), `action="/posts/`+root.ID+`/edit"`) { t.Fatalf("admin edit controls are incorrect: %d %s", rec.Code, rec.Body.String()) } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/", nil) + for _, cookie := range homeownerCookies { + req.AddCookie(cookie) + } + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("hunt page status = %d: %s", rec.Code, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "cdn.example") { + t.Fatalf("hunt page rendered post images: %s", rec.Body.String()) + } +} + +func TestImagePickerAssetsAreServed(t *testing.T) { + t.Parallel() + + srv, _ := newTestServer(t, Config{}) + handler := srv.Handler() + for _, asset := range []struct { + path string + wants []string + }{ + { + path: "/static/app.js", + wants: []string{ + `const pickerSelector = "[data-image-picker]"`, + `new DataTransfer()`, + `addEventListener("drop"`, + `resetImagePicker`, + `URL.revokeObjectURL`, + }, + }, + { + path: "/static/app.css", + wants: []string{ + `.image-dropzone`, + `.image-dropzone:focus-within`, + `.image-preview-list`, + `.post-image-grid`, + `@media (max-width: 520px)`, + }, + }, + } { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, asset.path, nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d", asset.path, rec.Code) + } + for _, want := range asset.wants { + if !strings.Contains(rec.Body.String(), want) { + t.Errorf("%s missing %q", asset.path, want) + } + } + } } func waitForMail(t *testing.T, recording *mail.Recording, want int) []mail.PostReply { diff --git a/internal/web/server.go b/internal/web/server.go index e4372b1..53a0eb2 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -104,6 +104,11 @@ type threadPostCtx struct { Depth int } +type imagePickerCtx struct { + ID string + Images []store.PostImage +} + func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) { if cfg.Blob == nil { cfg.Blob = blob.Disabled{} @@ -118,6 +123,12 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F "postCtx": func(user *store.User, csrf string, root, post *store.Post, depth int) threadPostCtx { return threadPostCtx{User: user, CSRF: csrf, Root: root, Post: post, Depth: depth} }, + "imagePicker": func(id string, images []store.PostImage) imagePickerCtx { + return imagePickerCtx{ID: id, Images: images} + }, + "newImagePicker": func(id string) imagePickerCtx { + return imagePickerCtx{ID: id} + }, "add": func(a, b int) int { return a + b }, "rank": func(i int) int { return i + 1 }, "isAdmin": func(u *store.User) bool { return u.Admin() }, diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 5a9393d..884431c 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -173,6 +173,11 @@ func TestRegisterLoginAsk(t *testing.T) { `id="submit-progress"`, `data-submit-once`, `data-submit-button`, + `enctype="multipart/form-data"`, + `data-image-picker`, + `id="submit-images"`, + `accept="image/jpeg,image/png,image/webp"`, + `Add up to 4 JPEG, PNG, or WebP images.`, } { if !strings.Contains(rec.Body.String(), want) { t.Fatalf("submit form missing %q: %s", want, rec.Body.String()) diff --git a/static/app.css b/static/app.css index 54f5e22..9af3012 100644 --- a/static/app.css +++ b/static/app.css @@ -749,6 +749,243 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: .post-form-actions .btn { flex: 1 1 10rem; } +.image-picker { + min-width: 0; + margin: 10px 0; + padding: 0; + border: 0; +} + +.image-picker legend { + margin-bottom: 6px; + padding: 0; + font-family: var(--mono); + font-weight: 500; + font-size: 0.68rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--muted); +} + +.image-picker-hint { + margin: 0 0 8px; + color: var(--muted); + font-family: var(--mono); + font-size: 0.7rem; + line-height: 1.5; + text-wrap: pretty; +} + +.image-dropzone { + position: relative; + min-height: 108px; + display: grid; + place-content: center; + gap: 8px; + padding: 18px 72px 18px 18px; + border: 1px dashed var(--zinc); + border-radius: 3px; + background: #181a1d; + transition: border-color 140ms ease, background-color 140ms ease; +} + +.image-dropzone:hover, +.image-dropzone.is-dragging { + border-color: var(--signal); + background: #202124; +} + +.image-dropzone:focus-within { + outline: 2px solid var(--signal); + outline-offset: 2px; +} + +.image-input { + position: absolute; + inset: 0; + z-index: 1; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.image-dropzone-label { + display: grid; + gap: 3px; + pointer-events: none; + text-align: center; + color: var(--ink); +} + +.image-dropzone-label strong { + font-family: var(--sans); + font-size: 0.95rem; + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.image-dropzone-label span { + color: var(--muted); + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.05em; +} + +.image-picker-count { + position: absolute; + top: 10px; + right: 10px; + padding: 3px 6px; + border: 1px solid var(--line); + color: var(--muted); + background: var(--panel); + font-family: var(--mono); + font-size: 0.65rem; + letter-spacing: 0.06em; + pointer-events: none; +} + +.image-picker-error { + margin: 8px 0 0; + color: #ffd0d0; + font-family: var(--mono); + font-size: 0.72rem; +} + +.image-preview-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 10px; +} + +.image-preview { + min-width: 0; + display: grid; + grid-template-rows: auto 1fr; + border: 1px solid var(--line); + border-radius: 3px; + overflow: hidden; + background: #141516; +} + +.image-preview[hidden] { display: none; } + +.image-preview-media { + position: relative; + aspect-ratio: 4 / 3; + background: var(--bg); + overflow: hidden; +} + +.image-preview-media img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.image-preview-tag { + position: absolute; + top: 8px; + left: 8px; + padding: 3px 6px; + background: rgba(20, 21, 22, 0.9); + border: 1px solid var(--line); + color: var(--ink); + font-family: var(--mono); + font-size: 0.6rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.image-preview-fields { + min-width: 0; + display: grid; + align-content: start; + gap: 7px; + padding: 10px; +} + +.image-preview-fields label { + display: grid; + gap: 6px; +} + +.image-preview-name { + margin: 0; + overflow: hidden; + color: var(--muted); + font-family: var(--mono); + font-size: 0.68rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.image-remove { + width: fit-content; + min-height: 44px; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + font-family: var(--mono); + font-size: 0.68rem; + letter-spacing: 0.06em; + text-decoration: underline; + text-transform: uppercase; + cursor: pointer; +} + +.image-remove:hover { color: #ffd0d0; } +.image-remove:focus-visible { + outline: 2px solid var(--signal); + outline-offset: 2px; +} + +.post-image-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin-top: 16px; +} + +.post-image-grid-1 { grid-template-columns: minmax(0, 1fr); } + +.post-image { + min-width: 0; + margin: 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 3px; + background: #141516; +} + +.post-image img { + width: 100%; + height: 100%; + max-height: 32rem; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.post-image-grid-1 .post-image img { + height: auto; + aspect-ratio: auto; + object-fit: contain; +} + +.post-image figcaption { + padding: 8px 10px; + border-top: 1px solid var(--line); + color: var(--muted); + font-family: var(--mono); + font-size: 0.7rem; + line-height: 1.45; + overflow-wrap: anywhere; +} + .post-permalink { display: inline-flex; align-items: center; @@ -797,8 +1034,16 @@ input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn: .panel-wrap { padding: 32px; } } +@media (max-width: 520px) { + .image-preview-list, + .post-image-grid { + grid-template-columns: minmax(0, 1fr); + } +} + @media (prefers-reduced-motion: reduce) { .btn-primary:hover { filter: none; } + .image-dropzone { transition: none; } .submit-progress-bar { width: 100%; animation: none; diff --git a/static/app.js b/static/app.js index 1ec32a4..ffe9651 100644 --- a/static/app.js +++ b/static/app.js @@ -1,5 +1,9 @@ (() => { const formSelector = "form[data-submit-once]"; + const pickerSelector = "[data-image-picker]"; + const allowedImageTypes = new Set(["image/jpeg", "image/png", "image/webp"]); + const maxImageBytes = 5 * 1024 * 1024; + const pickerStates = new WeakMap(); function progressIndicator() { return document.getElementById("submit-progress"); @@ -21,6 +25,193 @@ } } + function existingImageCount(picker) { + return picker.querySelectorAll("[data-existing-image]:not([hidden])").length; + } + + function updateImageCount(picker, state) { + const count = existingImageCount(picker) + state.entries.length; + const status = picker.querySelector("[data-image-count]"); + if (status) { + status.textContent = `${count} of ${state.max}`; + } + } + + function showImageError(picker, message) { + const error = picker.querySelector("[data-image-error]"); + if (!error) { + return; + } + error.textContent = message; + error.hidden = !message; + } + + function imageFileAllowed(file) { + if (allowedImageTypes.has(file.type)) { + return true; + } + if (file.type) { + return false; + } + return /\.(jpe?g|png|webp)$/i.test(file.name); + } + + function sameImageFile(left, right) { + return left.name === right.name && + left.size === right.size && + left.lastModified === right.lastModified; + } + + function syncImageInput(state) { + const transfer = new DataTransfer(); + state.entries.forEach((entry) => transfer.items.add(entry.file)); + state.input.files = transfer.files; + } + + function removeNewImage(picker, state, entry) { + const index = state.entries.indexOf(entry); + if (index === -1) { + return; + } + state.entries.splice(index, 1); + URL.revokeObjectURL(entry.previewURL); + entry.card.remove(); + syncImageInput(state); + showImageError(picker, ""); + updateImageCount(picker, state); + } + + function addImageFiles(picker, state, files) { + showImageError(picker, ""); + const uniqueFiles = files.filter((file) => + !state.entries.some((entry) => sameImageFile(entry.file, file)) + ); + const available = state.max - existingImageCount(picker) - state.entries.length; + if (uniqueFiles.length > available) { + showImageError( + picker, + available > 0 + ? `You can add ${available} more ${available === 1 ? "image" : "images"}.` + : "You already have 4 images selected." + ); + syncImageInput(state); + return; + } + for (const file of uniqueFiles) { + if (!imageFileAllowed(file)) { + showImageError(picker, "Images must be JPEG, PNG, or WebP."); + syncImageInput(state); + return; + } + if (file.size > maxImageBytes) { + showImageError(picker, `${file.name} is larger than 5 MB.`); + syncImageInput(state); + return; + } + } + + uniqueFiles.forEach((file) => { + const fragment = state.template.content.cloneNode(true); + const card = fragment.querySelector("[data-new-image]"); + const preview = fragment.querySelector("[data-image-preview]"); + const name = fragment.querySelector("[data-image-name]"); + const previewURL = URL.createObjectURL(file); + preview.src = previewURL; + if (name) { + name.textContent = file.name; + } + const entry = { file, card, previewURL }; + const removeButton = card.querySelector("[data-remove-image]"); + removeButton.setAttribute("aria-label", `Remove selected image: ${file.name}`); + removeButton.addEventListener("click", () => { + removeNewImage(picker, state, entry); + }); + state.list.appendChild(fragment); + state.entries.push(entry); + }); + syncImageInput(state); + updateImageCount(picker, state); + } + + function resetImagePicker(picker) { + const state = pickerStates.get(picker); + if (!state) { + return; + } + state.entries.forEach((entry) => { + URL.revokeObjectURL(entry.previewURL); + entry.card.remove(); + }); + state.entries = []; + state.input.value = ""; + picker.querySelectorAll("[data-existing-image]").forEach((card) => { + card.hidden = false; + card.querySelectorAll("input").forEach((input) => { + input.disabled = false; + }); + }); + showImageError(picker, ""); + updateImageCount(picker, state); + } + + function initializeImagePicker(picker) { + if ( + pickerStates.has(picker) || + typeof DataTransfer === "undefined" || + typeof URL.createObjectURL !== "function" + ) { + return; + } + const input = picker.querySelector("[data-image-input]"); + const dropzone = picker.querySelector("[data-image-dropzone]"); + const list = picker.querySelector("[data-image-list]"); + const template = picker.querySelector("[data-image-template]"); + if (!input || !dropzone || !list || !template) { + return; + } + const state = { + input, + list, + template, + entries: [], + max: Number.parseInt(picker.dataset.maxImages, 10) || 4, + }; + pickerStates.set(picker, state); + updateImageCount(picker, state); + + input.addEventListener("change", () => { + addImageFiles(picker, state, Array.from(input.files)); + }); + picker.querySelectorAll("[data-existing-image]").forEach((card) => { + card.querySelector("[data-remove-image]").addEventListener("click", () => { + card.hidden = true; + card.querySelectorAll("input").forEach((existingInput) => { + existingInput.disabled = true; + }); + showImageError(picker, ""); + updateImageCount(picker, state); + }); + }); + ["dragenter", "dragover"].forEach((eventName) => { + dropzone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropzone.classList.add("is-dragging"); + }); + }); + ["dragleave", "drop"].forEach((eventName) => { + dropzone.addEventListener(eventName, (event) => { + event.preventDefault(); + dropzone.classList.remove("is-dragging"); + }); + }); + dropzone.addEventListener("drop", (event) => { + addImageFiles(picker, state, Array.from(event.dataTransfer.files)); + }); + picker.closest("form")?.addEventListener("reset", () => { + window.setTimeout(() => resetImagePicker(picker), 0); + }); + } + document.addEventListener("submit", (event) => { const form = event.target.closest(formSelector); if (!form) { @@ -50,9 +241,12 @@ window.addEventListener("pageshow", () => { document.querySelectorAll(formSelector).forEach(resetForm); + document.querySelectorAll(pickerSelector).forEach(resetImagePicker); const progress = progressIndicator(); if (progress) { progress.hidden = true; } }); + + document.querySelectorAll(pickerSelector).forEach(initializeImagePicker); })(); diff --git a/templates/partials/_image_picker.html b/templates/partials/_image_picker.html new file mode 100644 index 0000000..610a35e --- /dev/null +++ b/templates/partials/_image_picker.html @@ -0,0 +1,85 @@ +{{define "imagePicker"}} +
+ Photos (optional) +

+ Add up to 4 JPEG, PNG, or WebP images. Each image can be up to 5 MB. +

+
+ + + {{len .Images}} of 4 +
+ + +
+ {{range .Images}} +
+
+ + Saved +
+
+ + + + +
+
+ {{end}} +
+ + + +
+{{end}} + +{{define "postImages"}} +{{if .Images}} +
+ {{range .Images}} +
+ {{if .Description}}{{.Description}}{{else}}Photo attached to this post{{end}} + {{if .Description}}
{{.Description}}
{{end}} +
+ {{end}} +
+{{end}} +{{end}} diff --git a/templates/partials/_post.html b/templates/partials/_post.html index 3f6fe63..aec6415 100644 --- a/templates/partials/_post.html +++ b/templates/partials/_post.html @@ -3,12 +3,13 @@ {{if canReply .User .Root}}
Reply -
+ {{template "imagePicker" (newImagePicker (printf "reply-images-%s" .Post.ID))}}
+
@@ -59,6 +63,7 @@

{{.Post.Body}}

+ {{template "postImages" .Post}} {{template "postActions" .}} {{if .Post.Replies}}
diff --git a/templates/question.html b/templates/question.html index 5df2022..79c05ba 100644 --- a/templates/question.html +++ b/templates/question.html @@ -17,6 +17,7 @@ {{if isEdited .Question}}Edited{{end}}

{{.Question.Body}}

+ {{template "postImages" .Question}} {{template "postActions" (postCtx .User .CSRF .Question .Question 0)}}
diff --git a/templates/submit.html b/templates/submit.html index bfb2175..ec14de6 100644 --- a/templates/submit.html +++ b/templates/submit.html @@ -4,7 +4,7 @@

Ask a question

It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.

{{if .Error}}{{end}} - @@ -13,6 +13,7 @@ + {{template "imagePicker" (newImagePicker "submit-images")}}
-- 2.43.0