feat: add edit/delete buttons to list views and inline privacy toggle

Fave cards in the list and profile views now show edit, delete, and
privacy toggle buttons directly — no need to open the detail page first.

- New POST /faves/{id}/privacy route with HTMX privacy toggle partial
- New UpdatePrivacy store method for single-column update
- fave_list.html: edit link, HTMX delete, privacy toggle on every card
- profile.html: edit/delete for owner's own cards
- privacy_toggle.html: new HTMX partial that swaps inline on toggle
- CSS: compact .fave-card-actions styles

The existing handleFaveDelete already returns empty 200 for HTMX
requests, so hx-target="closest article" hx-swap="outerHTML" removes
the card from DOM seamlessly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ole-Morten Duesund 2026-04-07 10:17:46 +02:00
commit b186fb4bc5
9 changed files with 360 additions and 3 deletions

View file

@ -335,6 +335,51 @@ func (h *Handler) handleFaveDelete(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther) http.Redirect(w, r, h.deps.Config.BasePath+"/faves", http.StatusSeeOther)
} }
// handleFaveTogglePrivacy toggles a fave's privacy and returns the updated toggle partial.
func (h *Handler) handleFaveTogglePrivacy(w http.ResponseWriter, r *http.Request) {
user := middleware.UserFromContext(r.Context())
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
h.notFound(w, r)
return
}
fave, err := h.deps.Faves.GetByID(id)
if err != nil {
if errors.Is(err, store.ErrFaveNotFound) {
h.notFound(w, r)
return
}
slog.Error("get fave error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if user.ID != fave.UserID {
h.forbidden(w, r)
return
}
newPrivacy := "private"
if fave.Privacy == "private" {
newPrivacy = "public"
}
if err := h.deps.Faves.UpdatePrivacy(id, newPrivacy); err != nil {
slog.Error("toggle privacy error", "error", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
fave.Privacy = newPrivacy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.deps.Renderer.Partial(w, "privacy_toggle", fave); err != nil {
slog.Error("render privacy toggle error", "error", err)
}
}
// handleTagSearch handles tag autocomplete HTMX requests. // handleTagSearch handles tag autocomplete HTMX requests.
func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) { func (h *Handler) handleTagSearch(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q") q := r.URL.Query().Get("q")

View file

@ -103,6 +103,7 @@ func (h *Handler) Routes() *http.ServeMux {
mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit))) mux.Handle("GET /faves/{id}/edit", requireLogin(http.HandlerFunc(h.handleFaveEdit)))
mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate))) mux.Handle("POST /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveUpdate)))
mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete))) mux.Handle("DELETE /faves/{id}", requireLogin(http.HandlerFunc(h.handleFaveDelete)))
mux.Handle("POST /faves/{id}/privacy", requireLogin(http.HandlerFunc(h.handleFaveTogglePrivacy)))
// Tags. // Tags.
mux.HandleFunc("GET /tags/search", h.handleTagSearch) mux.HandleFunc("GET /tags/search", h.handleTagSearch)
@ -138,6 +139,8 @@ func (h *Handler) Routes() *http.ServeMux {
mux.Handle("POST /admin/users", admin(h.handleAdminCreateUser)) mux.Handle("POST /admin/users", admin(h.handleAdminCreateUser))
mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword)) mux.Handle("POST /admin/users/{id}/reset-password", admin(h.handleAdminResetPassword))
mux.Handle("POST /admin/users/{id}/toggle-disabled", admin(h.handleAdminToggleDisabled)) mux.Handle("POST /admin/users/{id}/toggle-disabled", admin(h.handleAdminToggleDisabled))
mux.Handle("POST /admin/users/{id}/role", admin(h.handleAdminSetRole))
mux.Handle("POST /admin/users/{id}/delete", admin(h.handleAdminDeleteUser))
mux.Handle("GET /admin/tags", admin(h.handleAdminTags)) mux.Handle("GET /admin/tags", admin(h.handleAdminTags))
mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag)) mux.Handle("POST /admin/tags/{id}/rename", admin(h.handleAdminRenameTag))
mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag)) mux.Handle("POST /admin/tags/{id}/delete", admin(h.handleAdminDeleteTag))

View file

@ -1282,6 +1282,197 @@ func TestFaveNewPreFill(t *testing.T) {
} }
} }
// --- Privacy toggle ---
func TestTogglePrivacyOwner(t *testing.T) {
h, mux := testServer(t)
user, _ := h.deps.Users.Create("testuser", "pass123", "user")
fave, _ := h.deps.Faves.Create(user.ID, "Toggle me", "", "", "", "public")
token, _ := h.deps.Sessions.Create(user.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("toggle privacy: got %d, want 200\nbody: %s", rr.Code, rr.Body.String())
}
// Should now be private.
updated, _ := h.deps.Faves.GetByID(fave.ID)
if updated.Privacy != "private" {
t.Errorf("privacy = %q, want private after toggle from public", updated.Privacy)
}
// Response should contain the toggle partial with "Privat".
if !strings.Contains(rr.Body.String(), "Privat") {
t.Error("toggle response should show new privacy state")
}
}
func TestTogglePrivacyNotOwner(t *testing.T) {
h, mux := testServer(t)
userA, _ := h.deps.Users.Create("usera", "pass123", "user")
fave, _ := h.deps.Faves.Create(userA.ID, "A's fave", "", "", "", "public")
cookieB := loginUser(t, h, "userb", "pass123", "user")
getReq := httptest.NewRequest("GET", "/faves/"+faveIDStr(fave.ID), nil)
getReq.AddCookie(cookieB)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/faves/"+faveIDStr(fave.ID)+"/privacy", csrf, url.Values{}, cookieB)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusForbidden {
t.Errorf("toggle by non-owner: got %d, want 403", rr.Code)
}
}
func TestFaveListShowsEditButton(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "testuser", "pass123", "user")
user, _ := h.deps.Users.GetByUsername("testuser")
h.deps.Faves.Create(user.ID, "Editable fave", "", "", "", "public")
req := httptest.NewRequest("GET", "/faves", nil)
req.AddCookie(cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
body := rr.Body.String()
if !strings.Contains(body, "Rediger") {
t.Error("fave list should show edit link")
}
if !strings.Contains(body, "Slett") {
t.Error("fave list should show delete button")
}
if !strings.Contains(body, "Offentlig") {
t.Error("fave list should show privacy toggle")
}
}
// --- Admin: role + delete ---
func TestAdminSetRoleSuccess(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "admin", "pass123", "admin")
user, _ := h.deps.Users.Create("target", "pass123", "user")
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
form := url.Values{"role": {"admin"}}
req := postForm("/admin/users/"+faveIDStr(user.ID)+"/role", csrf, form, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "endret til admin") {
t.Errorf("should show role changed: %s", rr.Body.String())
}
updated, _ := h.deps.Users.GetByID(user.ID)
if updated.Role != "admin" {
t.Errorf("role = %q, want admin", updated.Role)
}
}
func TestAdminSetRoleSelf(t *testing.T) {
h, mux := testServer(t)
admin, _ := h.deps.Users.Create("admin", "pass123", "admin")
token, _ := h.deps.Sessions.Create(admin.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
form := url.Values{"role": {"user"}}
req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/role", csrf, form, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "din egen rolle") {
t.Error("should prevent changing own role")
}
}
func TestAdminDeleteUserSuccess(t *testing.T) {
h, mux := testServer(t)
cookie := loginUser(t, h, "admin", "pass123", "admin")
user, _ := h.deps.Users.Create("deleteme", "pass123", "user")
h.deps.Faves.Create(user.ID, "Will be deleted", "", "", "", "public")
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/admin/users/"+faveIDStr(user.ID)+"/delete", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "permanent slettet") {
t.Errorf("should show deleted: %s", rr.Body.String())
}
// User should be gone.
_, err := h.deps.Users.GetByUsername("deleteme")
if err == nil {
t.Error("deleted user should not exist")
}
// Faves should be cascade-deleted.
faves, total, _ := h.deps.Faves.ListByUser(user.ID, 10, 0)
if total != 0 || len(faves) != 0 {
t.Error("faves should be cascade-deleted with user")
}
}
func TestAdminDeleteUserSelf(t *testing.T) {
h, mux := testServer(t)
admin, _ := h.deps.Users.Create("admin", "pass123", "admin")
token, _ := h.deps.Sessions.Create(admin.ID)
cookie := &http.Cookie{Name: "session", Value: token}
getReq := httptest.NewRequest("GET", "/admin/users", nil)
getReq.AddCookie(cookie)
getRR := httptest.NewRecorder()
mux.ServeHTTP(getRR, getReq)
csrf := extractCookie(getRR, "csrf_token")
req := postForm("/admin/users/"+faveIDStr(admin.ID)+"/delete", csrf, url.Values{}, cookie)
rr := httptest.NewRecorder()
mux.ServeHTTP(rr, req)
if !strings.Contains(rr.Body.String(), "din egen konto") {
t.Error("should prevent self-deletion")
}
}
// --- Export page --- // --- Export page ---
func TestExportPageRendering(t *testing.T) { func TestExportPageRendering(t *testing.T) {

View file

@ -73,6 +73,17 @@ func (s *FaveStore) Update(id int64, description, url, imagePath, notes, privacy
return err return err
} }
// UpdatePrivacy toggles a fave's privacy setting.
func (s *FaveStore) UpdatePrivacy(id int64, privacy string) error {
_, err := s.db.Exec(
`UPDATE faves SET privacy = ?,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ?`,
privacy, id,
)
return err
}
// Delete removes a fave by its ID. The cascade will clean up fave_tags. // Delete removes a fave by its ID. The cascade will clean up fave_tags.
func (s *FaveStore) Delete(id int64) error { func (s *FaveStore) Delete(id int64) error {
result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id) result, err := s.db.Exec("DELETE FROM faves WHERE id = ?", id)

View file

@ -150,6 +150,37 @@ func TestFaveNotes(t *testing.T) {
} }
} }
func TestUpdatePrivacy(t *testing.T) {
db := testDB(t)
users := NewUserStore(db)
faves := NewFaveStore(db)
Argon2Memory = 1024
Argon2Time = 1
defer func() { Argon2Memory = 65536; Argon2Time = 3 }()
user, _ := users.Create("testuser", "password123", "user")
fave, _ := faves.Create(user.ID, "Toggle me", "", "", "", "public")
// Toggle to private.
err := faves.UpdatePrivacy(fave.ID, "private")
if err != nil {
t.Fatalf("update privacy: %v", err)
}
got, _ := faves.GetByID(fave.ID)
if got.Privacy != "private" {
t.Errorf("privacy = %q, want private", got.Privacy)
}
// Toggle back to public.
faves.UpdatePrivacy(fave.ID, "public")
got, _ = faves.GetByID(fave.ID)
if got.Privacy != "public" {
t.Errorf("privacy = %q, want public", got.Privacy)
}
}
func TestListByTag(t *testing.T) { func TestListByTag(t *testing.T) {
db := testDB(t) db := testDB(t)
users := NewUserStore(db) users := NewUserStore(db)

View file

@ -92,6 +92,40 @@
padding: 0 1rem 0.5rem; padding: 0 1rem 0.5rem;
} }
/* Card action buttons (edit/delete/privacy toggle) */
.fave-card-actions {
display: flex;
gap: 0.5rem;
padding: 0 1rem 0.5rem;
align-items: center;
}
.fave-action-link {
font-size: 0.8rem;
text-decoration: none;
}
.fave-action-btn {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
margin: 0;
border: 1px solid var(--pico-muted-border-color);
background: transparent;
cursor: pointer;
border-radius: var(--pico-border-radius);
color: inherit;
}
.fave-action-btn:hover {
border-color: var(--pico-primary);
color: var(--pico-primary);
}
.fave-action-btn.secondary:hover {
border-color: var(--pico-del-color);
color: var(--pico-del-color);
}
/* Privacy badge */ /* Privacy badge */
.badge-private { .badge-private {
background: var(--pico-muted-border-color); background: var(--pico-muted-border-color);

View file

@ -21,9 +21,6 @@
<a href="{{basePath}}/faves/{{.ID}}"> <a href="{{basePath}}/faves/{{.ID}}">
<strong>{{.Description}}</strong> <strong>{{.Description}}</strong>
</a> </a>
{{if eq .Privacy "private"}}
<small class="badge-private" aria-label="Privat">Privat</small>
{{end}}
</header> </header>
{{if .Tags}} {{if .Tags}}
<footer> <footer>
@ -32,6 +29,27 @@
{{end}} {{end}}
</footer> </footer>
{{end}} {{end}}
<footer class="fave-card-actions">
<span class="privacy-toggle" id="privacy-{{.ID}}">
<button
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
hx-target="#privacy-{{.ID}}"
hx-swap="outerHTML"
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
</span>
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
aria-label="Rediger {{.Description}}">Rediger</a>
<button
hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-target="closest article"
hx-swap="outerHTML"
class="fave-action-btn secondary"
aria-label="Slett {{.Description}}"
>Slett</button>
</footer>
</article> </article>
{{end}} {{end}}
</div> </div>

View file

@ -75,6 +75,20 @@
{{end}} {{end}}
</footer> </footer>
{{end}} {{end}}
{{if $d.IsOwner}}
<footer class="fave-card-actions">
<a href="{{basePath}}/faves/{{.ID}}/edit" class="fave-action-link"
aria-label="Rediger {{.Description}}">Rediger</a>
<button
hx-delete="{{basePath}}/faves/{{.ID}}"
hx-confirm="Er du sikker på at du vil slette denne favoritten?"
hx-target="closest article"
hx-swap="outerHTML"
class="fave-action-btn secondary"
aria-label="Slett {{.Description}}"
>Slett</button>
</footer>
{{end}}
</article> </article>
{{end}} {{end}}
</div> </div>

View file

@ -0,0 +1,10 @@
<span class="privacy-toggle" id="privacy-{{.ID}}">
<button
hx-post="{{basePath}}/faves/{{.ID}}/privacy"
hx-target="#privacy-{{.ID}}"
hx-swap="outerHTML"
class="fave-action-btn {{if eq .Privacy "private"}}secondary{{end}}"
aria-label="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
title="{{if eq .Privacy "public"}}Gjør privat{{else}}Gjør offentlig{{end}}"
>{{if eq .Privacy "public"}}Offentlig{{else}}Privat{{end}}</button>
</span>