This commit is contained in:
juancwu 2026-01-14 21:51:08 +00:00
commit dd7f2ebe3e
15 changed files with 373 additions and 70 deletions

View file

@ -2,5 +2,5 @@ package assets
import "embed" import "embed"
//go:embed js/* css/* //go:embed js/* css/* fonts/*
var AssetsFS embed.FS var AssetsFS embed.FS

Binary file not shown.

Binary file not shown.

2
go.mod
View file

@ -8,6 +8,7 @@ require (
github.com/alexedwards/argon2id v1.0.0 github.com/alexedwards/argon2id v1.0.0
github.com/emersion/go-imap v1.2.1 github.com/emersion/go-imap v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6 github.com/jackc/pgx/v5 v5.7.6
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
@ -39,7 +40,6 @@ require (
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect

View file

@ -5,6 +5,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/config" "git.juancwu.dev/juancwu/budgit/internal/config"
"git.juancwu.dev/juancwu/budgit/internal/db" "git.juancwu.dev/juancwu/budgit/internal/db"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
@ -13,6 +14,7 @@ import (
type App struct { type App struct {
Cfg *config.Config Cfg *config.Config
DB *sqlx.DB DB *sqlx.DB
EventBus *event.Broker
UserService *service.UserService UserService *service.UserService
AuthService *service.AuthService AuthService *service.AuthService
EmailService *service.EmailService EmailService *service.EmailService
@ -35,6 +37,8 @@ func New(cfg *config.Config) (*App, error) {
return nil, fmt.Errorf("failed to run migrations: %w", err) return nil, fmt.Errorf("failed to run migrations: %w", err)
} }
eventBus := event.NewBroker()
emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword) emailClient := service.NewEmailClient(cfg.MailerSMTPHost, cfg.MailerSMTPPort, cfg.MailerIMAPHost, cfg.MailerIMAPPort, cfg.MailerUsername, cfg.MailerPassword)
// Repositories // Repositories
@ -71,13 +75,14 @@ func New(cfg *config.Config) (*App, error) {
) )
profileService := service.NewProfileService(profileRepository) profileService := service.NewProfileService(profileRepository)
tagService := service.NewTagService(tagRepository) tagService := service.NewTagService(tagRepository)
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository) shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus)
expenseService := service.NewExpenseService(expenseRepository) expenseService := service.NewExpenseService(expenseRepository, eventBus)
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService) inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
return &App{ return &App{
Cfg: cfg, Cfg: cfg,
DB: database, DB: database,
EventBus: eventBus,
UserService: userService, UserService: userService,
AuthService: authService, AuthService: authService,
EmailService: emailService, EmailService: emailService,

84
internal/event/bus.go Normal file
View file

@ -0,0 +1,84 @@
package event
import (
"fmt"
"log/slog"
"sync"
)
type Event struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
type Broker struct {
mu sync.RWMutex
subscribers map[string][]chan Event // map[spaceID][]chan Event
}
func NewBroker() *Broker {
return &Broker{
subscribers: make(map[string][]chan Event),
}
}
func (b *Broker) Subscribe(spaceID string) chan Event {
b.mu.Lock()
defer b.mu.Unlock()
ch := make(chan Event, 10) // buffer slightly to prevent blocking
b.subscribers[spaceID] = append(b.subscribers[spaceID], ch)
slog.Info("new subscriber", "space_id", spaceID)
return ch
}
func (b *Broker) Unsubscribe(spaceID string, ch chan Event) {
b.mu.Lock()
defer b.mu.Unlock()
subs := b.subscribers[spaceID]
for i, sub := range subs {
if sub == ch {
// Remove from slice
b.subscribers[spaceID] = append(subs[:i], subs[i+1:]...)
close(ch)
slog.Info("subscriber removed", "space_id", spaceID)
break
}
}
if len(b.subscribers[spaceID]) == 0 {
delete(b.subscribers, spaceID)
}
}
func (b *Broker) Publish(spaceID string, eventType string, data interface{}) {
b.mu.RLock()
defer b.mu.RUnlock()
subs, ok := b.subscribers[spaceID]
if !ok {
return
}
event := Event{
Type: eventType,
Data: data,
}
slog.Info("publishing event", "space_id", spaceID, "type", eventType)
for _, ch := range subs {
select {
case ch <- event:
default:
slog.Warn("subscriber channel full, dropping event", "space_id", spaceID)
}
}
}
// String format for SSE data
func (e Event) String() string {
return fmt.Sprintf("event: %s\ndata: %v\n\n", e.Type, e.Data)
}

View file

@ -7,11 +7,14 @@ import (
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist" "git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag" "git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages" "git.juancwu.dev/juancwu/budgit/internal/ui/pages"
) )
@ -21,15 +24,54 @@ type SpaceHandler struct {
listService *service.ShoppingListService listService *service.ShoppingListService
expenseService *service.ExpenseService expenseService *service.ExpenseService
inviteService *service.InviteService inviteService *service.InviteService
eventBus *event.Broker
} }
func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService) *SpaceHandler { func NewSpaceHandler(ss *service.SpaceService, ts *service.TagService, sls *service.ShoppingListService, es *service.ExpenseService, is *service.InviteService, eb *event.Broker) *SpaceHandler {
return &SpaceHandler{ return &SpaceHandler{
spaceService: ss, spaceService: ss,
tagService: ts, tagService: ts,
listService: sls, listService: sls,
expenseService: es, expenseService: es,
inviteService: is, inviteService: is,
eventBus: eb,
}
}
func (h *SpaceHandler) StreamEvents(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
// Set headers for SSE
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("Access-Control-Allow-Origin", "*")
// Subscribe to events
eventChan := h.eventBus.Subscribe(spaceID)
defer h.eventBus.Unsubscribe(spaceID, eventChan)
// Listen for client disconnect
ctx := r.Context()
// Flush immediately to establish connection
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
for {
select {
case event := <-eventChan:
// Write event to stream
if _, err := w.Write([]byte(event.String())); err != nil {
return
}
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-ctx.Done():
return
}
} }
} }
@ -378,8 +420,14 @@ func (h *SpaceHandler) CreateInvite(w http.ResponseWriter, r *http.Request) {
return return
} }
// TODO: Return a nice UI response (toast or list update) ui.Render(w, r, toast.Toast(toast.Props{
w.Write([]byte("Invitation sent!")) Title: "Invitation sent",
Description: "An email has been sent to " + email,
Variant: toast.VariantSuccess,
Icon: true,
Dismissible: true,
Duration: 5000,
}))
} }
func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
@ -409,3 +457,56 @@ func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
}) })
http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect) http.Redirect(w, r, "/auth?invite=true", http.StatusTemporaryRedirect)
} }
func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
if err != nil {
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, expense.BalanceCard(spaceID, balance, false))
}
func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
expenses, err := h.expenseService.GetExpensesForSpace(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))
}
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
items, err := h.listService.GetItemsForList(listID)
if err != nil {
slog.Error("failed to get items", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ShoppingListItems(spaceID, items))
}
func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
slog.Error("failed to get lists", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ListsContainer(lists))
}

View file

@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
auth := handler.NewAuthHandler(a.AuthService, a.InviteService) auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
home := handler.NewHomeHandler() home := handler.NewHomeHandler()
dashboard := handler.NewDashboardHandler() dashboard := handler.NewDashboardHandler()
space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService) space := handler.NewSpaceHandler(a.SpaceService, a.TagService, a.ShoppingListService, a.ExpenseService, a.InviteService, a.EventBus)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -57,6 +57,11 @@ func SetupRoutes(a *app.App) http.Handler {
spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler) spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler)
mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess) mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess)
// SSE
streamHandler := middleware.RequireAuth(space.StreamEvents)
streamWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(streamHandler)
mux.Handle("GET /app/spaces/{spaceID}/stream", streamWithAccess)
listsPageHandler := middleware.RequireAuth(space.ListsPage) listsPageHandler := middleware.RequireAuth(space.ListsPage)
listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler) listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess) mux.Handle("GET /app/spaces/{spaceID}/lists", listsPageWithAccess)
@ -103,6 +108,23 @@ 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)
// Component routes (HTMX updates)
balanceCardHandler := middleware.RequireAuth(space.GetBalanceCard)
balanceCardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(balanceCardHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAccess)
expensesListHandler := middleware.RequireAuth(space.GetExpensesList)
expensesListWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(expensesListHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/expenses", expensesListWithAccess)
shoppingListItemsHandler := middleware.RequireAuth(space.GetShoppingListItems)
shoppingListItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(shoppingListItemsHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}/items", shoppingListItemsWithAccess)
listsComponentHandler := middleware.RequireAuth(space.GetLists)
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess)
// 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

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
@ -22,10 +23,14 @@ type CreateExpenseDTO struct {
type ExpenseService struct { type ExpenseService struct {
expenseRepo repository.ExpenseRepository expenseRepo repository.ExpenseRepository
eventBus *event.Broker
} }
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService { func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService {
return &ExpenseService{expenseRepo: expenseRepo} return &ExpenseService{
expenseRepo: expenseRepo,
eventBus: eventBus,
}
} }
func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) { func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, error) {
@ -54,6 +59,14 @@ func (s *ExpenseService) CreateExpense(dto CreateExpenseDTO) (*model.Expense, er
return nil, err return nil, err
} }
// Calculate new balance to broadcast
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 expense, nil return expense, nil
} }

View file

@ -5,6 +5,7 @@ import (
"strings" "strings"
"time" "time"
"git.juancwu.dev/juancwu/budgit/internal/event"
"git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/repository" "git.juancwu.dev/juancwu/budgit/internal/repository"
"github.com/google/uuid" "github.com/google/uuid"
@ -13,12 +14,14 @@ import (
type ShoppingListService struct { type ShoppingListService struct {
listRepo repository.ShoppingListRepository listRepo repository.ShoppingListRepository
itemRepo repository.ListItemRepository itemRepo repository.ListItemRepository
eventBus *event.Broker
} }
func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository) *ShoppingListService { func NewShoppingListService(listRepo repository.ShoppingListRepository, itemRepo repository.ListItemRepository, eventBus *event.Broker) *ShoppingListService {
return &ShoppingListService{ return &ShoppingListService{
listRepo: listRepo, listRepo: listRepo,
itemRepo: itemRepo, itemRepo: itemRepo,
eventBus: eventBus,
} }
} }
@ -43,6 +46,8 @@ func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingL
return nil, err return nil, err
} }
s.eventBus.Publish(spaceID, "list_created", nil)
return list, nil return list, nil
} }
@ -76,13 +81,23 @@ func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingLi
} }
func (s *ShoppingListService) DeleteList(listID string) error { func (s *ShoppingListService) DeleteList(listID string) error {
// Need spaceID to publish event
list, err := s.listRepo.GetByID(listID)
if err != nil {
return err
}
// First delete all items in the list // First delete all items in the list
err := s.itemRepo.DeleteByListID(listID) err = s.itemRepo.DeleteByListID(listID)
if err != nil { if err != nil {
return fmt.Errorf("failed to delete items in list: %w", err) return fmt.Errorf("failed to delete items in list: %w", err)
} }
// Then delete the list itself // Then delete the list itself
return s.listRepo.Delete(listID) err = s.listRepo.Delete(listID)
if err == nil {
s.eventBus.Publish(list.SpaceID, "list_deleted", nil)
}
return err
} }
// Item methods // Item methods
@ -108,6 +123,12 @@ func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*mo
return nil, err return nil, err
} }
// Get Space ID
list, err := s.listRepo.GetByID(listID)
if err == nil {
s.eventBus.Publish(list.SpaceID, "item_added", nil)
}
return item, nil return item, nil
} }
@ -138,9 +159,27 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*
return nil, err return nil, err
} }
// Get Space ID via List
list, err := s.listRepo.GetByID(item.ListID)
if err == nil {
s.eventBus.Publish(list.SpaceID, "item_updated", nil)
}
return item, nil return item, nil
} }
func (s *ShoppingListService) DeleteItem(itemID string) error { func (s *ShoppingListService) DeleteItem(itemID string) error {
return s.itemRepo.Delete(itemID) item, err := s.itemRepo.GetByID(itemID)
if err != nil {
return err
}
err = s.itemRepo.Delete(itemID)
if err == nil {
list, err := s.listRepo.GetByID(item.ListID)
if err == nil {
s.eventBus.Publish(list.SpaceID, "item_deleted", nil)
}
}
return err
} }

View file

@ -84,8 +84,15 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
</form> </form>
} }
templ BalanceCard(balance int, oob bool) { templ BalanceCard(spaceID string, balance int, oob bool) {
<div id="balance-card" class="border rounded-lg p-4 bg-card text-card-foreground" hx-swap-oob?={ oob }> <div
id="balance-card"
class="border rounded-lg p-4 bg-card text-card-foreground"
hx-swap-oob?={ oob }
hx-get={ "/app/spaces/" + spaceID + "/components/balance" }
hx-trigger="sse:balance_changed"
hx-swap="outerHTML"
>
<h2 class="text-lg font-semibold">Current Balance</h2> <h2 class="text-lg font-semibold">Current Balance</h2>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }> <p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) } { fmt.Sprintf("$%.2f", float64(balance)/100.0) }

View file

@ -93,42 +93,44 @@ templ Space(title string, space *model.Space) {
} }
} }
@sidebar.Inset() { @sidebar.Inset() {
// Top Navigation Bar <div hx-sse={ "connect:/app/spaces/" + space.ID + "/stream" } class="flex flex-col h-full">
<header class="sticky top-0 z-10 border-b bg-background"> // Top Navigation Bar
<div class="flex h-14 items-center px-6"> <header class="sticky top-0 z-10 border-b bg-background">
<div class="flex items-center gap-4"> <div class="flex h-14 items-center px-6">
@sidebar.Trigger() <div class="flex items-center gap-4">
@breadcrumb.Breadcrumb() { @sidebar.Trigger()
@breadcrumb.List() { @breadcrumb.Breadcrumb() {
@breadcrumb.Item() { @breadcrumb.List() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/dashboard"}) { @breadcrumb.Item() {
Home @breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/dashboard"}) {
Home
}
} }
} @breadcrumb.Separator()
@breadcrumb.Separator() @breadcrumb.Item() {
@breadcrumb.Item() { @breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) { { space.Name }
{ space.Name } }
} }
} @breadcrumb.Separator()
@breadcrumb.Separator() @breadcrumb.Item() {
@breadcrumb.Item() { @breadcrumb.Page() {
@breadcrumb.Page() { { title }
{ title } }
} }
} }
} }
} </div>
<div class="ml-auto flex items-center gap-4">
@blocks.ThemeSwitcher()
</div>
</div> </div>
<div class="ml-auto flex items-center gap-4"> </header>
@blocks.ThemeSwitcher() // App Content
</div> <main class="flex-1 p-6">
</div> { children... }
</header> </main>
// App Content </div>
<main class="flex-1 p-6">
{ children... }
</main>
} }
} }
} }

View file

@ -33,24 +33,34 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
</div> </div>
// Balance Card // Balance Card
@expense.BalanceCard(balance, false) @expense.BalanceCard(space.ID, balance, false)
// List of expenses // List of expenses
<div id="expenses-list" class="border rounded-lg"> <div
<h2 class="text-lg font-semibold p-4">History</h2> id="expenses-list"
<div class="divide-y"> class="border rounded-lg"
if len(expenses) == 0 { hx-get={ "/app/spaces/" + space.ID + "/components/expenses" }
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p> hx-trigger="sse:expenses_updated"
} hx-swap="innerHTML"
for _, expense := range expenses { >
@ExpenseListItem(expense) @ExpensesListContent(expenses)
}
</div>
</div> </div>
</div> </div>
} }
} }
templ ExpensesListContent(expenses []*model.Expense) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, expense := range expenses {
@ExpenseListItem(expense)
}
</div>
}
templ ExpenseListItem(expense *model.Expense) { templ ExpenseListItem(expense *model.Expense) {
<div class="p-4 flex justify-between items-center"> <div class="p-4 flex justify-between items-center">
<div> <div>
@ -73,5 +83,5 @@ templ ExpenseListItem(expense *model.Expense) {
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) { templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
@ExpenseListItem(newExpense) @ExpenseListItem(newExpense)
@expense.BalanceCard(balance, true) @expense.BalanceCard(newExpense.SpaceID, balance, true)
} }

View file

@ -31,15 +31,25 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
Add Item Add Item
} }
</form> </form>
<div id="items-container" class="border rounded-lg"> <div
if len(items) == 0 { id="items-container"
<p class="text-center text-muted-foreground p-8">This list is empty.</p> class="border rounded-lg"
} else { hx-get={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
for _, item := range items { hx-trigger="sse:item_added, sse:item_updated, sse:item_deleted"
@shoppinglist.ItemDetail(space.ID, item) hx-swap="innerHTML"
} >
} @ShoppingListItems(space.ID, items)
</div> </div>
</div> </div>
} }
} }
templ ShoppingListItems(spaceID string, items []*model.ListItem) {
if len(items) == 0 {
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
} else {
for _, item := range items {
@shoppinglist.ItemDetail(spaceID, item)
}
}
}

View file

@ -33,11 +33,21 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
Create Create
} }
</form> </form>
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div
for _, list := range lists { id="lists-container"
@shoppinglist.ListItem(list) class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
} hx-get={ "/app/spaces/" + space.ID + "/components/lists" }
hx-trigger="sse:list_created, sse:list_deleted"
hx-swap="innerHTML"
>
@ListsContainer(lists)
</div> </div>
</div> </div>
} }
} }
templ ListsContainer(lists []*model.ShoppingList) {
for _, list := range lists {
@shoppinglist.ListItem(list)
}
}