From 99a002c60771be88c450a906b88b7133e9bbe088 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 14:46:27 -0500 Subject: [PATCH 1/6] feat: extend expense card info and allow edit/delete of expense --- internal/handler/space.go | 151 ++++++++++++++++++- internal/model/expense.go | 5 + internal/repository/expense.go | 89 ++++++++++- internal/routes/routes.go | 8 + internal/service/expense.go | 90 +++++++++++ internal/ui/components/expense/expense.templ | 100 ++++++++++++ internal/ui/pages/app_space_expenses.templ | 97 ++++++++++-- 7 files changed, 516 insertions(+), 24 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 254f580..198aafe 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -38,6 +38,20 @@ func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *serv } } +// getExpenseForSpace fetches an expense and verifies it belongs to the given space. +func (h *SpaceHandler) getExpenseForSpace(w http.ResponseWriter, spaceID, expenseID string) *model.Expense { + exp, err := h.expenseService.GetExpense(expenseID) + if err != nil { + http.Error(w, "Expense not found", http.StatusNotFound) + return nil + } + if exp.SpaceID != spaceID { + http.Error(w, "Not Found", http.StatusNotFound) + return nil + } + return exp +} + // getListForSpace fetches a shopping list and verifies it belongs to the given space. // Returns the list on success, or writes an error response and returns nil. func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList { @@ -401,7 +415,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -558,7 +572,136 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { slog.Error("failed to get balance", "error", err, "space_id", spaceID) } - ui.Render(w, r, pages.ExpenseCreatedResponse(newExpense, balance)) + // Build tags for the newly created expense + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID}) + newExpenseWithTags := &model.ExpenseWithTags{ + Expense: *newExpense, + Tags: tagsMap[newExpense.ID], + } + + ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance)) +} + +func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + description := r.FormValue("description") + amountStr := r.FormValue("amount") + typeStr := r.FormValue("type") + dateStr := r.FormValue("date") + tagNames := r.Form["tags"] + + if description == "" || amountStr == "" || typeStr == "" || dateStr == "" { + http.Error(w, "All fields are required.", http.StatusBadRequest) + return + } + + amountFloat, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + http.Error(w, "Invalid amount format.", http.StatusBadRequest) + return + } + amountCents := int(amountFloat * 100) + + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + http.Error(w, "Invalid date format.", http.StatusBadRequest) + return + } + + expenseType := model.ExpenseType(typeStr) + if expenseType != model.ExpenseTypeExpense && expenseType != model.ExpenseTypeTopup { + http.Error(w, "Invalid transaction type.", http.StatusBadRequest) + return + } + + // Tag processing (same as CreateExpense) + existingTags, err := h.tagService.GetTagsForSpace(spaceID) + if err != nil { + slog.Error("failed to get tags for space", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + existingTagsMap := make(map[string]string) + for _, t := range existingTags { + existingTagsMap[t.Name] = t.ID + } + + var finalTagIDs []string + processedTags := make(map[string]bool) + + for _, rawTagName := range tagNames { + tagName := service.NormalizeTagName(rawTagName) + if tagName == "" || processedTags[tagName] { + continue + } + + if id, exists := existingTagsMap[tagName]; exists { + finalTagIDs = append(finalTagIDs, id) + } else { + newTag, err := h.tagService.CreateTag(spaceID, tagName, nil) + if err != nil { + slog.Error("failed to create new tag from expense form", "error", err, "tag_name", tagName) + continue + } + finalTagIDs = append(finalTagIDs, newTag.ID) + existingTagsMap[tagName] = newTag.ID + } + processedTags[tagName] = true + } + + dto := service.UpdateExpenseDTO{ + ID: expenseID, + SpaceID: spaceID, + Description: description, + Amount: amountCents, + Type: expenseType, + Date: date, + TagIDs: finalTagIDs, + } + + updatedExpense, err := h.expenseService.UpdateExpense(dto) + if err != nil { + slog.Error("failed to update expense", "error", err) + http.Error(w, "Failed to update expense.", http.StatusInternalServerError) + return + } + + tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{updatedExpense.ID}) + expWithTags := &model.ExpenseWithTags{ + Expense: *updatedExpense, + Tags: tagsMap[updatedExpense.ID], + } + + ui.Render(w, r, pages.ExpenseListItem(spaceID, expWithTags)) +} + +func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + expenseID := r.PathValue("expenseID") + + if h.getExpenseForSpace(w, spaceID, expenseID) == nil { + return + } + + if err := h.expenseService.DeleteExpense(expenseID, spaceID); err != nil { + slog.Error("failed to delete expense", "error", err, "expense_id", expenseID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) } func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { @@ -637,14 +780,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - expenses, err := h.expenseService.GetExpensesForSpace(spaceID) + expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) if err != nil { slog.Error("failed to get expenses", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.ExpensesListContent(expenses)) + ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses)) } func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { diff --git a/internal/model/expense.go b/internal/model/expense.go index 48713e3..fa15f3e 100644 --- a/internal/model/expense.go +++ b/internal/model/expense.go @@ -21,6 +21,11 @@ type Expense struct { UpdatedAt time.Time `db:"updated_at"` } +type ExpenseWithTags struct { + Expense + Tags []*Tag +} + type ExpenseTag struct { ExpenseID string `db:"expense_id"` TagID string `db:"tag_id"` diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 60d89b9..18588eb 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -18,6 +18,9 @@ type ExpenseRepository interface { GetByID(id string) (*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) + GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) + Update(expense *model.Expense, tagIDs []string) error + Delete(id string) error } type expenseRepository struct { @@ -91,10 +94,10 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { var summaries []*model.TagExpenseSummary query := ` - SELECT - t.id as tag_id, - t.name as tag_name, - t.color as tag_color, + SELECT + t.id as tag_id, + t.name as tag_name, + t.color as tag_color, SUM(e.amount_cents) as total_amount FROM expenses e JOIN expense_tags et ON e.id = et.expense_id @@ -109,3 +112,81 @@ func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate ti } return summaries, nil } + +func (r *expenseRepository) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + if len(expenseIDs) == 0 { + return make(map[string][]*model.Tag), nil + } + + type row struct { + ExpenseID string `db:"expense_id"` + ID string `db:"id"` + SpaceID string `db:"space_id"` + Name string `db:"name"` + Color *string `db:"color"` + } + + query, args, err := sqlx.In(` + SELECT et.expense_id, t.id, t.space_id, t.name, t.color + FROM expense_tags et + JOIN tags t ON et.tag_id = t.id + WHERE et.expense_id IN (?) + ORDER BY t.name; + `, expenseIDs) + if err != nil { + return nil, err + } + query = r.db.Rebind(query) + + var rows []row + if err := r.db.Select(&rows, query, args...); err != nil { + return nil, err + } + + result := make(map[string][]*model.Tag) + for _, rw := range rows { + result[rw.ExpenseID] = append(result[rw.ExpenseID], &model.Tag{ + ID: rw.ID, + SpaceID: rw.SpaceID, + Name: rw.Name, + Color: rw.Color, + }) + } + return result, nil +} + +func (r *expenseRepository) Update(expense *model.Expense, tagIDs []string) error { + tx, err := r.db.Beginx() + if err != nil { + return err + } + defer tx.Rollback() + + query := `UPDATE expenses SET description = $1, amount_cents = $2, type = $3, date = $4, updated_at = $5 WHERE id = $6;` + _, err = tx.Exec(query, expense.Description, expense.AmountCents, expense.Type, expense.Date, expense.UpdatedAt, expense.ID) + if err != nil { + return err + } + + // Replace tags: delete all existing, re-insert + _, err = tx.Exec(`DELETE FROM expense_tags WHERE expense_id = $1;`, expense.ID) + if err != nil { + return err + } + + if len(tagIDs) > 0 { + insertTag := `INSERT INTO expense_tags (expense_id, tag_id) VALUES ($1, $2);` + for _, tagID := range tagIDs { + if _, err := tx.Exec(insertTag, expense.ID, tagID); err != nil { + return err + } + } + } + + return tx.Commit() +} + +func (r *expenseRepository) Delete(id string) error { + _, err := r.db.Exec(`DELETE FROM expenses WHERE id = $1;`, id) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index a671cc6..c64dbb0 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -122,6 +122,14 @@ func SetupRoutes(a *app.App) http.Handler { createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) + updateExpenseHandler := middleware.RequireAuth(space.UpdateExpense) + updateExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateExpenseHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/expenses/{expenseID}", updateExpenseWithAccess) + + deleteExpenseHandler := middleware.RequireAuth(space.DeleteExpense) + deleteExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(deleteExpenseHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/expenses/{expenseID}", deleteExpenseWithAccess) + // Component routes (HTMX updates) balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard) balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler) diff --git a/internal/service/expense.go b/internal/service/expense.go index 96bf545..9d367b4 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -21,6 +21,16 @@ type CreateExpenseDTO struct { ItemIDs []string } +type UpdateExpenseDTO struct { + ID string + SpaceID string + Description string + Amount int + Type model.ExpenseType + Date time.Time + TagIDs []string +} + type ExpenseService struct { expenseRepo repository.ExpenseRepository eventBus *event.Broker @@ -95,3 +105,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) { func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate) } + +func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.ExpenseWithTags, error) { + expenses, err := s.expenseRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + + ids := make([]string, len(expenses)) + for i, e := range expenses { + ids[i] = e.ID + } + + tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids) + if err != nil { + return nil, err + } + + result := make([]*model.ExpenseWithTags, len(expenses)) + for i, e := range expenses { + result[i] = &model.ExpenseWithTags{ + Expense: *e, + Tags: tagsMap[e.ID], + } + } + return result, nil +} + +func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) { + return s.expenseRepo.GetByID(id) +} + +func (s *ExpenseService) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) { + return s.expenseRepo.GetTagsByExpenseIDs(expenseIDs) +} + +func (s *ExpenseService) UpdateExpense(dto UpdateExpenseDTO) (*model.Expense, error) { + if dto.Description == "" { + return nil, fmt.Errorf("expense description cannot be empty") + } + if dto.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + + existing, err := s.expenseRepo.GetByID(dto.ID) + if err != nil { + return nil, err + } + + existing.Description = dto.Description + existing.AmountCents = dto.Amount + existing.Type = dto.Type + existing.Date = dto.Date + existing.UpdatedAt = time.Now() + + if err := s.expenseRepo.Update(existing, dto.TagIDs); err != nil { + return nil, err + } + + balance, _ := s.GetBalanceForSpace(dto.SpaceID) + s.eventBus.Publish(dto.SpaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(dto.SpaceID, "expenses_updated", nil) + + return existing, nil +} + +func (s *ExpenseService) DeleteExpense(id string, spaceID string) error { + if err := s.expenseRepo.Delete(id); err != nil { + return err + } + + balance, _ := s.GetBalanceForSpace(spaceID) + s.eventBus.Publish(spaceID, "balance_changed", map[string]interface{}{ + "balance": balance, + }) + s.eventBus.Publish(spaceID, "expenses_updated", nil) + + return nil +} diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 0cf2e7e..228868c 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -209,6 +209,106 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []mod } +templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) { + {{ editDialogID := "edit-expense-" + exp.ID }} + {{ tagValues := make([]string, len(exp.Tags)) }} + for i, t := range exp.Tags { + {{ tagValues[i] = t.Name }} + } +
+ @csrf.Token() + // Type +
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-expense-" + exp.ID, + Name: "type", + Value: "expense", + Checked: exp.Type == model.ExpenseTypeExpense, + }) +
+ @label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) { + Expense + } +
+
+
+ @radio.Radio(radio.Props{ + ID: "edit-type-topup-" + exp.ID, + Name: "type", + Value: "topup", + Checked: exp.Type == model.ExpenseTypeTopup, + }) +
+ @label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) { + Top-up + } +
+
+
+ // Description +
+ @label.Label(label.Props{For: "edit-description-" + exp.ID}) { + Description + } + @input.Input(input.Props{ + Name: "description", + ID: "edit-description-" + exp.ID, + Value: exp.Description, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Amount +
+ @label.Label(label.Props{For: "edit-amount-" + exp.ID}) { + Amount + } + @input.Input(input.Props{ + Name: "amount", + ID: "edit-amount-" + exp.ID, + Type: "number", + Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0), + Attributes: templ.Attributes{"step": "0.01", "required": "true"}, + }) +
+ // Date +
+ @label.Label(label.Props{For: "edit-date-" + exp.ID}) { + Date + } + @datepicker.DatePicker(datepicker.Props{ + ID: "edit-date-" + exp.ID, + Name: "date", + Value: exp.Date, + Attributes: templ.Attributes{"required": "true"}, + }) +
+ // Tags +
+ @label.Label(label.Props{For: "edit-tags-" + exp.ID}) { + Tags + } + @tagsinput.TagsInput(tagsinput.Props{ + ID: "edit-tags-" + exp.ID, + Name: "tags", + Value: tagValues, + Placeholder: "Add tags (press enter)", + }) +
+
+ @button.Button(button.Props{Type: button.TypeSubmit}) { + Save + } +
+
+} + templ BalanceCard(spaceID string, balance int, oob bool) {
@@ -42,45 +44,108 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i hx-trigger="sse:expenses_updated" hx-swap="innerHTML" > - @ExpensesListContent(expenses) + @ExpensesListContent(space.ID, expenses)
} } -templ ExpensesListContent(expenses []*model.Expense) { +templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {

History

if len(expenses) == 0 {

No expenses recorded yet.

} - for _, expense := range expenses { - @ExpenseListItem(expense) + for _, exp := range expenses { + @ExpenseListItem(spaceID, exp) }
} -templ ExpenseListItem(expense *model.Expense) { -
-
-

{ expense.Description }

-

{ expense.Date.Format("Jan 02, 2006") }

+templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) { +
+
+

{ exp.Description }

+

{ exp.Date.Format("Jan 02, 2006") }

+ if len(exp.Tags) > 0 { +
+ for _, t := range exp.Tags { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + { t.Name } + } + } +
+ }
-
- if expense.Type == model.ExpenseTypeExpense { +
+ if exp.Type == model.ExpenseTypeExpense {

- - { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + - { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} else {

- + { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + + { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }

} + // Edit button + @dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Pencil(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Edit Transaction + } + @dialog.Description() { + Update the details of this transaction. + } + } + @expense.EditExpenseForm(spaceID, exp) + } + } + // Delete button + @dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) { + @dialog.Trigger() { + @button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) { + @icon.Trash2(icon.Props{Size: 14}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Delete Transaction + } + @dialog.Description() { + Are you sure you want to delete "{ exp.Description }"? This action cannot be undone. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{Variant: button.VariantOutline}) { + Cancel + } + } + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Attributes: templ.Attributes{ + "hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID), + "hx-target": "#expense-" + exp.ID, + "hx-swap": "outerHTML", + }, + }) { + Delete + } + } + } + }
} -templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { - @ExpenseListItem(newExpense) +templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, newExpense) @expense.BalanceCard(newExpense.SpaceID, balance, true) } From 948aa092edef3bd79c1a90f5a57a08dd50256b4c Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 14:54:20 -0500 Subject: [PATCH 2/6] fix: expense edits does not update balance card --- internal/handler/space.go | 14 ++++++++++++-- internal/ui/pages/app_space_expenses.templ | 5 +++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 198aafe..7884b4d 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -684,7 +684,12 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { Tags: tagsMap[updatedExpense.ID], } - ui.Render(w, r, pages.ExpenseListItem(spaceID, expWithTags)) + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after update", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTags, balance)) } func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { @@ -701,7 +706,12 @@ func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) + balance, err := h.expenseService.GetBalanceForSpace(spaceID) + if err != nil { + slog.Error("failed to get balance after delete", "error", err, "space_id", spaceID) + } + + ui.Render(w, r, expense.BalanceCard(spaceID, balance, true)) } func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) { diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ index 2b69570..0e3e0ad 100644 --- a/internal/ui/pages/app_space_expenses.templ +++ b/internal/ui/pages/app_space_expenses.templ @@ -149,3 +149,8 @@ templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, @ExpenseListItem(spaceID, newExpense) @expense.BalanceCard(newExpense.SpaceID, balance, true) } + +templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) { + @ExpenseListItem(spaceID, exp) + @expense.BalanceCard(exp.SpaceID, balance, true) +} From 5d5c1b281d393aa5e44ee497baa9df68f4109133 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 15:42:45 -0500 Subject: [PATCH 3/6] feat: allow change templ proxy port via env --- Taskfile.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Taskfile.yml b/Taskfile.yml index 1327422..f43599b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -1,13 +1,14 @@ version: "3" vars: TEMPL_PROXYBIND: '{{.TEMPL_PROXYBIND | default "127.0.0.1"}}' + TEMPL_PROXYPORT: '{{.TEMPL_PROXYPORT | default "7331"}}' TEMPL_PROXY: '{{.TEMPL_PROXY | default "http://127.0.0.1:9000"}}' tasks: # Development Tools templ: desc: Run templ with integrated server and hot reload cmds: - - go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false + - go tool templ generate --watch --cmd="go run ./cmd/server/main.go" --proxybind="{{.TEMPL_PROXYBIND}}" --proxyport="{{.TEMPL_PROXYPORT}}" --proxy="{{.TEMPL_PROXY}}" --open-browser=false tailwind-clean: desc: Clean tailwind output cmds: From 05e6158d959e266b73731527d77cc1e275eb37d9 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 16:00:28 -0500 Subject: [PATCH 4/6] feat: create space --- internal/handler/dashboard.go | 21 +++++++++++ internal/routes/routes.go | 1 + internal/ui/pages/app_dashboard.templ | 53 ++++++++++++++++++++++++--- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index e3f29ce..4c2d479 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -3,6 +3,7 @@ package handler import ( "log/slog" "net/http" + "strings" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/service" @@ -43,3 +44,23 @@ func (h *dashboardHandler) DashboardPage(w http.ResponseWriter, r *http.Request) ui.Render(w, r, pages.Dashboard(spaces, totalBalance)) } + +func (h *dashboardHandler) CreateSpace(w http.ResponseWriter, r *http.Request) { + user := ctxkeys.User(r.Context()) + + name := strings.TrimSpace(r.FormValue("name")) + if name == "" { + http.Error(w, "Space name is required", http.StatusBadRequest) + return + } + + space, err := h.spaceService.CreateSpace(name, user.ID) + if err != nil { + slog.Error("failed to create space", "error", err, "user_id", user.ID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.Header().Set("HX-Redirect", "/app/spaces/"+space.ID) + w.WriteHeader(http.StatusOK) +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index c64dbb0..4e7c4e1 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -55,6 +55,7 @@ func SetupRoutes(a *app.App) http.Handler { mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding))) mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) + mux.HandleFunc("POST /app/spaces", middleware.RequireAuth(dashboard.CreateSpace)) mux.HandleFunc("GET /app/settings", middleware.RequireAuth(settings.SettingsPage)) mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword))) diff --git a/internal/ui/pages/app_dashboard.templ b/internal/ui/pages/app_dashboard.templ index 8ac0743..8144972 100644 --- a/internal/ui/pages/app_dashboard.templ +++ b/internal/ui/pages/app_dashboard.templ @@ -4,7 +4,13 @@ import ( "fmt" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" ) templ Dashboard(spaces []*model.Space, totalBalance int) { @@ -43,11 +49,48 @@ templ Dashboard(spaces []*model.Space, totalBalance int) { } // Option to create a new space - @card.Card(card.Props{ Class: "h-full border-dashed" }) { - @card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) { -

Need another space?

- // TODO: Add a button or link to create a new space - Create Space (Coming Soon) + @dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) { + @dialog.Trigger() { + @card.Card(card.Props{ Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary" }) { + @card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) { + @icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"}) +

Create a new space

+ } + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Create Space + } + @dialog.Description() { + Create a new space to organize expenses and shopping lists. + } + } +
+ @csrf.Token() +
+ @label.Label(label.Props{For: "space-name"}) { + Name + } + @input.Input(input.Props{ + ID: "space-name", + Name: "name", + Type: input.TypeText, + Placeholder: "e.g. Household, Trip, Roommates", + }) +
+ @dialog.Footer() { + @dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) { + @button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) { + Cancel + } + } + @button.Button(button.Props{Type: button.TypeSubmit}) { + Create + } + } +
} }
From 1b5c57704e0ce78b7d37b5d4baa07ca2aed05e6f Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:16:16 -0500 Subject: [PATCH 5/6] feat: dedicated settings page for space --- .gitignore | 1 + internal/handler/space.go | 155 ++++++++++++ internal/model/space.go | 9 + internal/repository/space.go | 22 ++ internal/routes/routes.go | 21 ++ internal/service/invite.go | 13 + internal/service/space.go | 22 ++ internal/ui/layouts/space.templ | 10 + internal/ui/pages/app_space_settings.templ | 263 +++++++++++++++++++++ 9 files changed, 516 insertions(+) create mode 100644 internal/ui/pages/app_space_settings.templ diff --git a/.gitignore b/.gitignore index 32b4d3b..45b63b8 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,4 @@ tmp/ *.db-wal output.css +.session diff --git a/internal/handler/space.go b/internal/handler/space.go index 72b2a8e..2a2614a 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -878,6 +878,161 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) } +func (h *SpaceHandler) SettingsPage(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + slog.Error("failed to get space", "error", err, "space_id", spaceID) + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + members, err := h.spaceService.GetMembers(spaceID) + if err != nil { + slog.Error("failed to get members", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + isOwner := space.OwnerID == user.ID + + var pendingInvites []*model.SpaceInvitation + if isOwner { + pendingInvites, err = h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } + + ui.Render(w, r, pages.SpaceSettingsPage(space, members, pendingInvites, isOwner, user.ID)) +} + +func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Bad Request", http.StatusBadRequest) + return + } + + name := r.FormValue("name") + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + if err := h.spaceService.UpdateSpaceName(spaceID, name); err != nil { + slog.Error("failed to update space name", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, toast.Toast(toast.Props{ + Title: "Space renamed", + Description: "Space name has been updated.", + Variant: toast.VariantSuccess, + Icon: true, + Dismissible: true, + Duration: 5000, + })) +} + +func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + userID := r.PathValue("userID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if userID == user.ID { + http.Error(w, "Cannot remove yourself", http.StatusBadRequest) + return + } + + if err := h.spaceService.RemoveMember(spaceID, userID); err != nil { + slog.Error("failed to remove member", "error", err, "space_id", spaceID, "user_id", userID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) CancelInvite(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + token := r.PathValue("token") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if err := h.inviteService.CancelInvite(token); err != nil { + slog.Error("failed to cancel invite", "error", err, "space_id", spaceID, "token", token) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} + +func (h *SpaceHandler) GetPendingInvites(w http.ResponseWriter, r *http.Request) { + spaceID := r.PathValue("spaceID") + user := ctxkeys.User(r.Context()) + + space, err := h.spaceService.GetSpace(spaceID) + if err != nil { + http.Error(w, "Space not found", http.StatusNotFound) + return + } + + if space.OwnerID != user.ID { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + pendingInvites, err := h.inviteService.GetPendingInvites(spaceID) + if err != nil { + slog.Error("failed to get pending invites", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + ui.Render(w, r, pages.PendingInvitesList(spaceID, pendingInvites)) +} + func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) { lists, err := h.listService.GetListsForSpace(spaceID) if err != nil { diff --git a/internal/model/space.go b/internal/model/space.go index 93e8c9e..f18856c 100644 --- a/internal/model/space.go +++ b/internal/model/space.go @@ -23,3 +23,12 @@ type SpaceMember struct { Role Role `db:"role"` JoinedAt time.Time `db:"joined_at"` } + +type SpaceMemberWithProfile struct { + SpaceID string `db:"space_id"` + UserID string `db:"user_id"` + Role Role `db:"role"` + JoinedAt time.Time `db:"joined_at"` + Name string `db:"name"` + Email string `db:"email"` +} diff --git a/internal/repository/space.go b/internal/repository/space.go index 0f7db6e..a01f38c 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -20,6 +20,8 @@ type SpaceRepository interface { AddMember(spaceID, userID string, role model.Role) error RemoveMember(spaceID, userID string) error IsMember(spaceID, userID string) (bool, error) + GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) + UpdateName(spaceID, name string) error } type spaceRepository struct { @@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) { } return count > 0, nil } + +func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + var members []*model.SpaceMemberWithProfile + query := ` + SELECT sm.space_id, sm.user_id, sm.role, sm.joined_at, + p.name, u.email + FROM space_members sm + JOIN users u ON sm.user_id = u.id + JOIN profiles p ON sm.user_id = p.user_id + WHERE sm.space_id = $1 + ORDER BY sm.role DESC, sm.joined_at ASC;` + err := r.db.Select(&members, query, spaceID) + return members, err +} + +func (r *spaceRepository) UpdateName(spaceID, name string) error { + query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;` + _, err := r.db.Exec(query, name, time.Now(), spaceID) + return err +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index c64dbb0..be2bc19 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -151,6 +151,27 @@ func SetupRoutes(a *app.App) http.Handler { listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) + // Settings routes + settingsPageHandler := middleware.RequireAuth(space.SettingsPage) + settingsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(settingsPageHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings", settingsPageWithAccess) + + updateSpaceNameHandler := middleware.RequireAuth(space.UpdateSpaceName) + updateSpaceNameWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(updateSpaceNameHandler) + mux.Handle("PATCH /app/spaces/{spaceID}/settings/name", updateSpaceNameWithAccess) + + removeMemberHandler := middleware.RequireAuth(space.RemoveMember) + removeMemberWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(removeMemberHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/members/{userID}", removeMemberWithAccess) + + cancelInviteHandler := middleware.RequireAuth(space.CancelInvite) + cancelInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(cancelInviteHandler) + mux.Handle("DELETE /app/spaces/{spaceID}/invites/{token}", cancelInviteWithAccess) + + getPendingInvitesHandler := middleware.RequireAuth(space.GetPendingInvites) + getPendingInvitesWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(getPendingInvitesHandler) + mux.Handle("GET /app/spaces/{spaceID}/settings/invites", getPendingInvitesWithAccess) + // Invite routes createInviteHandler := middleware.RequireAuth(space.CreateInvite) createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) diff --git a/internal/service/invite.go b/internal/service/invite.go index d12d040..1907a03 100644 --- a/internal/service/invite.go +++ b/internal/service/invite.go @@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) { return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) } +func (s *InviteService) CancelInvite(token string) error { + invite, err := s.inviteRepo.GetByToken(token) + if err != nil { + return err + } + + if invite.Status != model.InvitationStatusPending { + return errors.New("invitation is not pending") + } + + return s.inviteRepo.Delete(token) +} + func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) { // Filter for pending only in memory or repo? // Repo returns all. diff --git a/internal/service/space.go b/internal/service/space.go index 5a3fd59..d3990b2 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) { } return isMember, nil } + +// GetMembers returns all members of a space with their profile info. +func (s *SpaceService) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) { + members, err := s.spaceRepo.GetMembers(spaceID) + if err != nil { + return nil, fmt.Errorf("failed to get members: %w", err) + } + return members, nil +} + +// RemoveMember removes a member from a space. +func (s *SpaceService) RemoveMember(spaceID, userID string) error { + return s.spaceRepo.RemoveMember(spaceID, userID) +} + +// UpdateSpaceName updates the name of a space. +func (s *SpaceService) UpdateSpaceName(spaceID, name string) error { + if name == "" { + return fmt.Errorf("space name cannot be empty") + } + return s.spaceRepo.UpdateName(spaceID, name) +} diff --git a/internal/ui/layouts/space.templ b/internal/ui/layouts/space.templ index f05ffe7..7727fe4 100644 --- a/internal/ui/layouts/space.templ +++ b/internal/ui/layouts/space.templ @@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) { Tags } } + @sidebar.MenuItem() { + @sidebar.MenuButton(sidebar.MenuButtonProps{ + Href: "/app/spaces/" + space.ID + "/settings", + IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings", + Tooltip: "Settings", + }) { + @icon.Settings(icon.Props{Class: "size-4"}) + Settings + } + } } } } diff --git a/internal/ui/pages/app_space_settings.templ b/internal/ui/pages/app_space_settings.templ new file mode 100644 index 0000000..a33e19d --- /dev/null +++ b/internal/ui/pages/app_space_settings.templ @@ -0,0 +1,263 @@ +package pages + +import ( + "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" + "git.juancwu.dev/juancwu/budgit/internal/model" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/badge" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/card" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" +) + +templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) { + @layouts.Space("Settings", space) { +
+ // Space Name Section + @card.Card() { + @card.Header() { + @card.Title() { + Space Name + } + @card.Description() { + if isOwner { + Update the name of this space. + } else { + The name of this space. + } + } + } + @card.Content() { + if isOwner { +
+ @csrf.Token() + @input.Input(input.Props{ + Name: "name", + Value: space.Name, + Attributes: templ.Attributes{ + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + Save + } +
+ } else { +

{ space.Name }

+ } + } + } + // Members Section + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Users(icon.Props{Class: "size-5"}) + Members +
+ } + @card.Description() { + People who have access to this space. + } + } + @card.Content() { +
+ for _, member := range members { + @MemberRow(space.ID, member, isOwner, currentUserID) + } +
+ } + } + // Invitations Section (owner only) + if isOwner { + @card.Card() { + @card.Header() { + @card.Title() { +
+ @icon.Mail(icon.Props{Class: "size-5"}) + Invitations +
+ } + @card.Description() { + Invite new members and manage pending invitations. + } + } + @card.Content() { +
+
+ @csrf.Token() + @input.Input(input.Props{ + Name: "email", + Placeholder: "Email address...", + Attributes: templ.Attributes{ + "type": "email", + "autocomplete": "off", + "required": true, + }, + }) + @button.Button(button.Props{ + Type: button.TypeSubmit, + }) { + @icon.UserPlus(icon.Props{Class: "size-4"}) + Invite + } +
+
+ if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(space.ID, invite) + } +
+ } else { +

No pending invitations.

+ } +
+
+ } + } + } +
+ @dialog.Script() + } +} + +templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) { +
+
+
+ { string([]rune(member.Name)[0]) } +
+
+

{ member.Name }

+

{ member.Email }

+
+
+
+ if member.Role == model.RoleOwner { + @badge.Badge(badge.Props{Variant: badge.VariantDefault}) { + @icon.Crown(icon.Props{Class: "size-3"}) + Owner + } + } else { + @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { + Member + } + } + if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner { + {{ dialogID := "remove-member-dialog-" + member.UserID }} + @dialog.Dialog(dialog.Props{ID: dialogID}) { + @dialog.Trigger() { + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + }) { + @icon.UserMinus(icon.Props{Class: "size-4 text-destructive"}) + } + } + @dialog.Content() { + @dialog.Header() { + @dialog.Title() { + Remove member + } + @dialog.Description() { + Are you sure you want to remove { member.Name } from this space? They will lose access immediately. + } + } + @dialog.Footer() { + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantOutline, + Type: button.TypeButton, + }) { + Cancel + } + } + @dialog.Close() { + @button.Button(button.Props{ + Variant: button.VariantDestructive, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID, + "hx-target": "#member-" + member.UserID, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + Remove + } + } + } + } + } + } +
+
+} + +templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) { +
+
+
+ @icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"}) +
+
+

{ invite.Email }

+

Sent { invite.CreatedAt.Format("Jan 02, 2006") }

+
+
+
+ @badge.Badge(badge.Props{Variant: badge.VariantOutline}) { + Pending + } + @button.Button(button.Props{ + Variant: button.VariantGhost, + Size: button.SizeIcon, + Type: button.TypeButton, + Attributes: templ.Attributes{ + "hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token, + "hx-target": "#invite-" + invite.Token, + "hx-swap": "outerHTML", + "hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`, + }, + }) { + @icon.X(icon.Props{Class: "size-4 text-destructive"}) + } +
+
+} + +templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) { + if len(pendingInvites) > 0 { +

Pending invitations

+
+ for _, invite := range pendingInvites { + @PendingInviteRow(spaceID, invite) + } +
+ } else { +

No pending invitations.

+ } +} From ff541ab2a96887406e63164cd02b8d9239a93378 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:20:14 -0500 Subject: [PATCH 6/6] fix: sidebar and breadcrumbs do not update after space name change --- internal/handler/space.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index 2a2614a..cc8528e 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -943,14 +943,8 @@ func (h *SpaceHandler) UpdateSpaceName(w http.ResponseWriter, r *http.Request) { return } - ui.Render(w, r, toast.Toast(toast.Props{ - Title: "Space renamed", - Description: "Space name has been updated.", - Variant: toast.VariantSuccess, - Icon: true, - Dismissible: true, - Duration: 5000, - })) + w.Header().Set("HX-Refresh", "true") + w.WriteHeader(http.StatusOK) } func (h *SpaceHandler) RemoveMember(w http.ResponseWriter, r *http.Request) {