Merge branch 'main' into fix/sse

This commit is contained in:
juancwu 2026-02-08 00:22:58 +00:00
commit 9044a64b10
17 changed files with 1118 additions and 35 deletions

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ tmp/
*.db-wal *.db-wal
output.css output.css
.session

View file

@ -1,13 +1,14 @@
version: "3" version: "3"
vars: vars:
TEMPL_PROXYBIND: '{{.TEMPL_PROXYBIND | default "127.0.0.1"}}' 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"}}' TEMPL_PROXY: '{{.TEMPL_PROXY | default "http://127.0.0.1:9000"}}'
tasks: tasks:
# Development Tools # Development Tools
templ: templ:
desc: Run templ with integrated server and hot reload desc: Run templ with integrated server and hot reload
cmds: 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: tailwind-clean:
desc: Clean tailwind output desc: Clean tailwind output
cmds: cmds:

View file

@ -3,6 +3,7 @@ package handler
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service" "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)) 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)
}

View file

@ -35,6 +35,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. // 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. // 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 { func (h *SpaceHandler) getListForSpace(w http.ResponseWriter, spaceID, listID string) *model.ShoppingList {
@ -368,7 +382,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
return return
} }
expenses, err := h.expenseService.GetExpensesForSpace(spaceID) expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@ -542,7 +556,146 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
return return
} }
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],
}
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) {
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
}
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) { func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
@ -621,14 +774,14 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID") spaceID := r.PathValue("spaceID")
expenses, err := h.expenseService.GetExpensesForSpace(spaceID) expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID)
if err != nil { if err != nil {
slog.Error("failed to get expenses", "error", err, "space_id", spaceID) slog.Error("failed to get expenses", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError) http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return 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) { func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
@ -685,6 +838,155 @@ func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request)
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages)) 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
}
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusOK)
}
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) { func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID) lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil { if err != nil {

View file

@ -21,6 +21,11 @@ type Expense struct {
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
type ExpenseWithTags struct {
Expense
Tags []*Tag
}
type ExpenseTag struct { type ExpenseTag struct {
ExpenseID string `db:"expense_id"` ExpenseID string `db:"expense_id"`
TagID string `db:"tag_id"` TagID string `db:"tag_id"`

View file

@ -23,3 +23,12 @@ type SpaceMember struct {
Role Role `db:"role"` Role Role `db:"role"`
JoinedAt time.Time `db:"joined_at"` 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"`
}

View file

@ -18,6 +18,9 @@ type ExpenseRepository interface {
GetByID(id string) (*model.Expense, error) GetByID(id string) (*model.Expense, error)
GetBySpaceID(spaceID string) ([]*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error)
GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, 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 { 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) { func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
var summaries []*model.TagExpenseSummary var summaries []*model.TagExpenseSummary
query := ` query := `
SELECT SELECT
t.id as tag_id, t.id as tag_id,
t.name as tag_name, t.name as tag_name,
t.color as tag_color, t.color as tag_color,
SUM(e.amount_cents) as total_amount SUM(e.amount_cents) as total_amount
FROM expenses e FROM expenses e
JOIN expense_tags et ON e.id = et.expense_id 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 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
}

View file

@ -20,6 +20,8 @@ type SpaceRepository interface {
AddMember(spaceID, userID string, role model.Role) error AddMember(spaceID, userID string, role model.Role) error
RemoveMember(spaceID, userID string) error RemoveMember(spaceID, userID string) error
IsMember(spaceID, userID string) (bool, error) IsMember(spaceID, userID string) (bool, error)
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
UpdateName(spaceID, name string) error
} }
type spaceRepository struct { type spaceRepository struct {
@ -106,3 +108,23 @@ func (r *spaceRepository) IsMember(spaceID, userID string) (bool, error) {
} }
return count > 0, nil 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
}

View file

@ -55,6 +55,7 @@ func SetupRoutes(a *app.App) http.Handler {
mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding))) mux.HandleFunc("POST /auth/onboarding", authRateLimiter(middleware.RequireAuth(auth.CompleteOnboarding)))
mux.HandleFunc("GET /app/dashboard", middleware.RequireAuth(dashboard.DashboardPage)) 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("GET /app/settings", middleware.RequireAuth(settings.SettingsPage))
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword))) mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
@ -117,6 +118,14 @@ func SetupRoutes(a *app.App) http.Handler {
createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler) createExpenseWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createExpenseHandler)
mux.Handle("POST /app/spaces/{spaceID}/expenses", createExpenseWithAccess) 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) // Component routes (HTMX updates)
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard) balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler) balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
@ -138,6 +147,27 @@ func SetupRoutes(a *app.App) http.Handler {
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler) listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess) 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 // Invite routes
createInviteHandler := middleware.RequireAuth(space.CreateInvite) createInviteHandler := middleware.RequireAuth(space.CreateInvite)
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler) createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)

View file

@ -20,6 +20,16 @@ type CreateExpenseDTO struct {
ItemIDs []string ItemIDs []string
} }
type UpdateExpenseDTO struct {
ID string
SpaceID string
Description string
Amount int
Type model.ExpenseType
Date time.Time
TagIDs []string
}
type ExpenseService struct { type ExpenseService struct {
expenseRepo repository.ExpenseRepository expenseRepo repository.ExpenseRepository
} }
@ -84,3 +94,83 @@ func (s *ExpenseService) GetBalanceForSpace(spaceID string) (int, error) {
func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { func (s *ExpenseService) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) {
return s.expenseRepo.GetExpensesByTag(spaceID, fromDate, toDate) 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
}

View file

@ -94,6 +94,19 @@ func (s *InviteService) AcceptInvite(token, userID string) (string, error) {
return invite.SpaceID, s.inviteRepo.UpdateStatus(token, model.InvitationStatusAccepted) 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) { func (s *InviteService) GetPendingInvites(spaceID string) ([]*model.SpaceInvitation, error) {
// Filter for pending only in memory or repo? // Filter for pending only in memory or repo?
// Repo returns all. // Repo returns all.

View file

@ -88,3 +88,25 @@ func (s *SpaceService) IsMember(userID, spaceID string) (bool, error) {
} }
return isMember, nil 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)
}

View file

@ -232,6 +232,106 @@ templ AddExpenseForm(props AddExpenseFormProps) {
</form> </form>
} }
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 }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
hx-target={ "#expense-" + exp.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-expense-" + exp.ID,
Name: "type",
Value: "expense",
Checked: exp.Type == model.ExpenseTypeExpense,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-topup-" + exp.ID,
Name: "type",
Value: "topup",
Checked: exp.Type == model.ExpenseTypeTopup,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@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"},
})
</div>
// Amount
<div>
@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"},
})
</div>
// Date
<div>
@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"},
})
</div>
// Tags
<div>
@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)",
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ BalanceCard(spaceID string, balance int, oob bool) { templ BalanceCard(spaceID string, balance int, oob bool) {
<div <div
id="balance-card" id="balance-card"

View file

@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) {
<span>Tags</span> <span>Tags</span>
} }
} }
@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"})
<span>Settings</span>
}
}
} }
} }
} }

View file

@ -4,7 +4,13 @@ import (
"fmt" "fmt"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "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/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) { templ Dashboard(spaces []*model.Space, totalBalance int) {
@ -43,11 +49,48 @@ templ Dashboard(spaces []*model.Space, totalBalance int) {
} }
// Option to create a new space // Option to create a new space
@card.Card(card.Props{ Class: "h-full border-dashed" }) { @dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) {
@card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) { @dialog.Trigger() {
<p class="text-muted-foreground mb-4">Need another space?</p> @card.Card(card.Props{ Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary" }) {
// TODO: Add a button or link to create a new space @card.Content(card.ContentProps{ Class: "h-full flex flex-col items-center justify-center py-12" }) {
<span class="text-sm font-medium opacity-50">Create Space (Coming Soon)</span> @icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"})
<p class="text-muted-foreground">Create a new space</p>
}
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Create Space
}
@dialog.Description() {
Create a new space to organize expenses and shopping lists.
}
}
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">
@csrf.Token()
<div class="space-y-2">
@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",
})
</div>
@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
}
}
</form>
} }
} }
</div> </div>

View file

@ -3,13 +3,15 @@ package pages
import ( import (
"fmt" "fmt"
"git.juancwu.dev/juancwu/budgit/internal/model" "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/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog" "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense" "git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts" "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
) )
templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) { templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
@layouts.Space("Expenses", space) { @layouts.Space("Expenses", space) {
<div class="space-y-4"> <div class="space-y-4">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
@ -30,11 +32,11 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
} }
} }
@expense.AddExpenseForm(expense.AddExpenseFormProps{ @expense.AddExpenseForm(expense.AddExpenseFormProps{
Space: space, Space: space,
Tags: tags, Tags: tags,
ListsWithItems: listsWithItems, ListsWithItems: listsWithItems,
DialogID: "add-expense-dialog", DialogID: "add-expense-dialog",
}) })
} }
} }
</div> </div>
@ -42,45 +44,113 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
@expense.BalanceCard(space.ID, balance, false) @expense.BalanceCard(space.ID, balance, false)
// List of expenses // List of expenses
<div class="border rounded-lg"> <div class="border rounded-lg">
@ExpensesListContent(expenses) @ExpensesListContent(space.ID, expenses)
</div> </div>
</div> </div>
} }
} }
templ ExpensesListContent(expenses []*model.Expense) { templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
<h2 class="text-lg font-semibold p-4">History</h2> <h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y"> <div id="expenses-list" class="divide-y">
if len(expenses) == 0 { if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p> <p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
} }
for _, expense := range expenses { for _, exp := range expenses {
@ExpenseListItem(expense) @ExpenseListItem(spaceID, exp)
} }
</div> </div>
} }
templ ExpenseListItem(expense *model.Expense) { templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
<div class="p-4 flex justify-between items-center"> <div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div> <div class="min-w-0 flex-1">
<p class="font-medium">{ expense.Description }</p> <p class="font-medium">{ exp.Description }</p>
<p class="text-sm text-muted-foreground">{ expense.Date.Format("Jan 02, 2006") }</p> <p class="text-sm text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div> </div>
<div> <div class="flex items-center gap-1 shrink-0">
if expense.Type == model.ExpenseTypeExpense { if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive"> <p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } - { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p> </p>
} else { } else {
<p class="font-bold text-green-500"> <p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) } + { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p> </p>
} }
// 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
}
}
}
}
</div> </div>
</div> </div>
} }
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) {
@ExpenseListItem(newExpense) @ExpenseListItem(spaceID, newExpense)
@expense.BalanceCard(newExpense.SpaceID, balance, true) @expense.BalanceCard(newExpense.SpaceID, balance, true)
} }
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) {
@ExpenseListItem(spaceID, exp)
@expense.BalanceCard(exp.SpaceID, balance, true)
}

View file

@ -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) {
<div class="space-y-6 max-w-2xl">
// 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 {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
hx-swap="none"
class="flex gap-2 items-start"
>
@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
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Members Section
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Users(icon.Props{Class: "size-5"})
Members
</div>
}
@card.Description() {
People who have access to this space.
}
}
@card.Content() {
<div class="divide-y" id="members-list">
for _, member := range members {
@MemberRow(space.ID, member, isOwner, currentUserID)
}
</div>
}
}
// Invitations Section (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Mail(icon.Props{Class: "size-5"})
Invitations
</div>
}
@card.Description() {
Invite new members and manage pending invitations.
}
}
@card.Content() {
<div class="space-y-4">
<form
hx-post={ "/app/spaces/" + space.ID + "/invites" }
hx-swap="none"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
class="flex gap-2 items-start"
>
@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
}
</form>
<div
id="pending-invites"
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
hx-trigger="refreshInvites from:body"
hx-swap="innerHTML"
>
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(space.ID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
</div>
</div>
}
}
}
</div>
@dialog.Script()
}
}
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{ string([]rune(member.Name)[0]) }
</div>
<div>
<p class="text-sm font-medium">{ member.Name }</p>
<p class="text-xs text-muted-foreground">{ member.Email }</p>
</div>
</div>
<div class="flex items-center gap-2">
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
}
}
}
}
}
}
</div>
</div>
}
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div>
<p class="text-sm font-medium">{ invite.Email }</p>
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
</div>
</div>
<div class="flex items-center gap-2">
@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"})
}
</div>
</div>
}
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(spaceID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
}