Add post image upload interface (#12)

Adds accessible drag-and-drop and browse controls, staged attachment editing, responsive lazy-loaded thread image grids, and UI regression coverage.

Reviewed-on: #12
Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #12.
This commit is contained in:
2026-08-29 08:01:04 +00:00
committed by codegirl007
parent 677e63329d
commit 394c7413c4
9 changed files with 641 additions and 4 deletions
+90
View File
@@ -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"`,
`<figcaption>Replacement cartridge orientation</figcaption>`,
`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</a>"); 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 {