add SSE
This commit is contained in:
parent
4d6e6799a0
commit
dd7f2ebe3e
15 changed files with 373 additions and 70 deletions
|
|
@ -2,5 +2,5 @@ package assets
|
|||
|
||||
import "embed"
|
||||
|
||||
//go:embed js/* css/*
|
||||
//go:embed js/* css/* fonts/*
|
||||
var AssetsFS embed.FS
|
||||
|
|
|
|||
BIN
assets/fonts/geist/geist-mono-variable.woff2
Normal file
BIN
assets/fonts/geist/geist-mono-variable.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/geist/geist-variable.woff2
Normal file
BIN
assets/fonts/geist/geist-variable.woff2
Normal file
Binary file not shown.
2
go.mod
2
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
|||
github.com/alexedwards/argon2id v1.0.0
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
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/jmoiron/sqlx v1.4.0
|
||||
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/sqlexp v0.1.0 // 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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/config"
|
||||
"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/service"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
|
@ -13,6 +14,7 @@ import (
|
|||
type App struct {
|
||||
Cfg *config.Config
|
||||
DB *sqlx.DB
|
||||
EventBus *event.Broker
|
||||
UserService *service.UserService
|
||||
AuthService *service.AuthService
|
||||
EmailService *service.EmailService
|
||||
|
|
@ -35,6 +37,8 @@ func New(cfg *config.Config) (*App, error) {
|
|||
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)
|
||||
|
||||
// Repositories
|
||||
|
|
@ -71,13 +75,14 @@ func New(cfg *config.Config) (*App, error) {
|
|||
)
|
||||
profileService := service.NewProfileService(profileRepository)
|
||||
tagService := service.NewTagService(tagRepository)
|
||||
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository)
|
||||
expenseService := service.NewExpenseService(expenseRepository)
|
||||
shoppingListService := service.NewShoppingListService(shoppingListRepository, listItemRepository, eventBus)
|
||||
expenseService := service.NewExpenseService(expenseRepository, eventBus)
|
||||
inviteService := service.NewInviteService(invitationRepository, spaceRepository, userRepository, emailService)
|
||||
|
||||
return &App{
|
||||
Cfg: cfg,
|
||||
DB: database,
|
||||
EventBus: eventBus,
|
||||
UserService: userService,
|
||||
AuthService: authService,
|
||||
EmailService: emailService,
|
||||
|
|
|
|||
84
internal/event/bus.go
Normal file
84
internal/event/bus.go
Normal 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)
|
||||
}
|
||||
|
|
@ -7,11 +7,14 @@ import (
|
|||
"time"
|
||||
|
||||
"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/service"
|
||||
"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/tag"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/toast"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/pages"
|
||||
)
|
||||
|
||||
|
|
@ -21,15 +24,54 @@ type SpaceHandler struct {
|
|||
listService *service.ShoppingListService
|
||||
expenseService *service.ExpenseService
|
||||
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{
|
||||
spaceService: ss,
|
||||
tagService: ts,
|
||||
listService: sls,
|
||||
expenseService: es,
|
||||
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
|
||||
}
|
||||
|
||||
// TODO: Return a nice UI response (toast or list update)
|
||||
w.Write([]byte("Invitation sent!"))
|
||||
ui.Render(w, r, toast.Toast(toast.Props{
|
||||
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) {
|
||||
|
|
@ -409,3 +457,56 @@ func (h *SpaceHandler) JoinSpace(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
auth := handler.NewAuthHandler(a.AuthService, a.InviteService)
|
||||
home := handler.NewHomeHandler()
|
||||
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()
|
||||
|
||||
|
|
@ -57,6 +57,11 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler)
|
||||
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)
|
||||
listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler)
|
||||
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)
|
||||
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
|
||||
createInviteHandler := middleware.RequireAuth(space.CreateInvite)
|
||||
createInviteWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(createInviteHandler)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/event"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -22,10 +23,14 @@ type CreateExpenseDTO struct {
|
|||
|
||||
type ExpenseService struct {
|
||||
expenseRepo repository.ExpenseRepository
|
||||
eventBus *event.Broker
|
||||
}
|
||||
|
||||
func NewExpenseService(expenseRepo repository.ExpenseRepository) *ExpenseService {
|
||||
return &ExpenseService{expenseRepo: expenseRepo}
|
||||
func NewExpenseService(expenseRepo repository.ExpenseRepository, eventBus *event.Broker) *ExpenseService {
|
||||
return &ExpenseService{
|
||||
expenseRepo: expenseRepo,
|
||||
eventBus: eventBus,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/event"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/repository"
|
||||
"github.com/google/uuid"
|
||||
|
|
@ -13,12 +14,14 @@ import (
|
|||
type ShoppingListService struct {
|
||||
listRepo repository.ShoppingListRepository
|
||||
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{
|
||||
listRepo: listRepo,
|
||||
itemRepo: itemRepo,
|
||||
eventBus: eventBus,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -43,6 +46,8 @@ func (s *ShoppingListService) CreateList(spaceID, name string) (*model.ShoppingL
|
|||
return nil, err
|
||||
}
|
||||
|
||||
s.eventBus.Publish(spaceID, "list_created", nil)
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
|
|
@ -76,13 +81,23 @@ func (s *ShoppingListService) UpdateList(listID, name string) (*model.ShoppingLi
|
|||
}
|
||||
|
||||
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
|
||||
err := s.itemRepo.DeleteByListID(listID)
|
||||
err = s.itemRepo.DeleteByListID(listID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete items in list: %w", err)
|
||||
}
|
||||
// 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
|
||||
|
|
@ -108,6 +123,12 @@ func (s *ShoppingListService) AddItemToList(listID, name, createdBy string) (*mo
|
|||
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
|
||||
}
|
||||
|
||||
|
|
@ -138,9 +159,27 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*
|
|||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,8 +84,15 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
|
|||
</form>
|
||||
}
|
||||
|
||||
templ BalanceCard(balance int, oob bool) {
|
||||
<div id="balance-card" class="border rounded-lg p-4 bg-card text-card-foreground" hx-swap-oob?={ oob }>
|
||||
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 }
|
||||
hx-get={ "/app/spaces/" + spaceID + "/components/balance" }
|
||||
hx-trigger="sse:balance_changed"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<h2 class="text-lg font-semibold">Current Balance</h2>
|
||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance < 0) }>
|
||||
{ fmt.Sprintf("$%.2f", float64(balance)/100.0) }
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ templ Space(title string, space *model.Space) {
|
|||
}
|
||||
}
|
||||
@sidebar.Inset() {
|
||||
<div hx-sse={ "connect:/app/spaces/" + space.ID + "/stream" } class="flex flex-col h-full">
|
||||
// Top Navigation Bar
|
||||
<header class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex h-14 items-center px-6">
|
||||
|
|
@ -129,6 +130,7 @@ templ Space(title string, space *model.Space) {
|
|||
<main class="flex-1 p-6">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,10 +33,23 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
|||
</div>
|
||||
|
||||
// Balance Card
|
||||
@expense.BalanceCard(balance, false)
|
||||
@expense.BalanceCard(space.ID, balance, false)
|
||||
|
||||
// List of expenses
|
||||
<div id="expenses-list" class="border rounded-lg">
|
||||
<div
|
||||
id="expenses-list"
|
||||
class="border rounded-lg"
|
||||
hx-get={ "/app/spaces/" + space.ID + "/components/expenses" }
|
||||
hx-trigger="sse:expenses_updated"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@ExpensesListContent(expenses)
|
||||
</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 {
|
||||
|
|
@ -46,9 +59,6 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
|||
@ExpenseListItem(expense)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
templ ExpenseListItem(expense *model.Expense) {
|
||||
|
|
@ -73,5 +83,5 @@ templ ExpenseListItem(expense *model.Expense) {
|
|||
|
||||
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
|
||||
@ExpenseListItem(newExpense)
|
||||
@expense.BalanceCard(balance, true)
|
||||
@expense.BalanceCard(newExpense.SpaceID, balance, true)
|
||||
}
|
||||
|
|
@ -31,15 +31,25 @@ templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []
|
|||
Add Item
|
||||
}
|
||||
</form>
|
||||
<div id="items-container" class="border rounded-lg">
|
||||
<div
|
||||
id="items-container"
|
||||
class="border rounded-lg"
|
||||
hx-get={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
|
||||
hx-trigger="sse:item_added, sse:item_updated, sse:item_deleted"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@ShoppingListItems(space.ID, items)
|
||||
</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(space.ID, item)
|
||||
@shoppinglist.ItemDetail(spaceID, item)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,21 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
|
|||
Create
|
||||
}
|
||||
</form>
|
||||
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div
|
||||
id="lists-container"
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
templ ListsContainer(lists []*model.ShoppingList) {
|
||||
for _, list := range lists {
|
||||
@shoppinglist.ListItem(list)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue