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"}}
+
+{{end}}
+
+{{define "postImages"}}
+{{if .Images}}
+
+ {{range .Images}}
+
+
+ {{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
-