feat: show shopping list items in cards

This commit is contained in:
juancwu 2026-02-07 18:27:06 +00:00
commit 6c704828ce
14 changed files with 396 additions and 85 deletions

11
assets/js/htmx-csrf.js Normal file
View file

@ -0,0 +1,11 @@
document.addEventListener('DOMContentLoaded', function() {
// Listen for htmx requests and add CSRF token header
document.body.addEventListener('htmx:configRequest', function(event) {
// Get CSRF token from meta tag
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) {
// Add token as X-CSRF-Token header to all HTMX requests
event.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
}
});
});

View file

@ -0,0 +1,18 @@
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
// Only prevent default for same-page anchors
const href = this.getAttribute('href');
if (href && href !== '#' && href.startsWith('#')) {
const target = document.querySelector(href);
if (target) {
e.preventDefault();
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
});
});
});

13
assets/js/theme.js Normal file
View file

@ -0,0 +1,13 @@
// Apply saved theme or system preference on load
if (localStorage.theme === 'dark' || (!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// Theme toggle handler
document.addEventListener('click', (e) => {
if (e.target.closest('[data-theme-switcher]')) {
e.preventDefault();
const isDark = document.documentElement.classList.toggle('dark');
localStorage.theme = isDark ? 'dark' : 'light';
}
});

3
go.mod
View file

@ -4,7 +4,6 @@ go 1.25.1
require (
github.com/Oudwins/tailwind-merge-go v0.2.1
github.com/a-h/templ v0.3.960
github.com/alexedwards/argon2id v1.0.0
github.com/emersion/go-imap v1.2.1
github.com/golang-jwt/jwt/v5 v5.2.2
@ -22,6 +21,7 @@ require (
github.com/ClickHouse/ch-go v0.67.0 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
github.com/a-h/templ v0.3.977 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
@ -59,7 +59,6 @@ require (
github.com/segmentio/asm v1.2.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/templui/templui v0.101.0 // indirect
github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect
github.com/vertica/vertica-sql-go v1.3.3 // indirect
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect

4
go.sum
View file

@ -25,6 +25,8 @@ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ6
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
github.com/a-h/templ v0.3.960 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
@ -48,6 +50,7 @@ github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWH
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -184,6 +187,7 @@ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmd
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -125,14 +125,14 @@ func (h *SpaceHandler) ListsPage(w http.ResponseWriter, r *http.Request) {
return
}
lists, err := h.listService.GetListsForSpace(spaceID)
cards, err := h.buildListCards(spaceID)
if err != nil {
slog.Error("failed to get lists for space", "error", err, "space_id", spaceID)
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceListsPage(space, lists))
ui.Render(w, r, pages.SpaceListsPage(space, cards))
}
func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) {
@ -158,7 +158,7 @@ func (h *SpaceHandler) CreateList(w http.ResponseWriter, r *http.Request) {
return
}
ui.Render(w, r, shoppinglist.ListItem(newList))
ui.Render(w, r, shoppinglist.ListCard(spaceID, newList, nil, 1, 1))
}
func (h *SpaceHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
@ -187,8 +187,12 @@ func (h *SpaceHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
return
}
if r.URL.Query().Get("from") == "card" {
ui.Render(w, r, shoppinglist.ListCardHeader(spaceID, updatedList))
} else {
ui.Render(w, r, shoppinglist.ListNameHeader(spaceID, updatedList))
}
}
func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
@ -205,7 +209,9 @@ func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
return
}
if r.URL.Query().Get("from") != "card" {
w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists")
}
w.WriteHeader(http.StatusOK)
}
@ -293,8 +299,12 @@ func (h *SpaceHandler) ToggleItem(w http.ResponseWriter, r *http.Request) {
return
}
if r.URL.Query().Get("from") == "card" {
ui.Render(w, r, shoppinglist.CardItemDetail(spaceID, updatedItem))
} else {
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem))
}
}
func (h *SpaceHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
@ -636,12 +646,58 @@ func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Reque
func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
lists, err := h.listService.GetListsForSpace(spaceID)
cards, err := h.buildListCards(spaceID)
if err != nil {
slog.Error("failed to get lists", "error", err, "space_id", spaceID)
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.ListsContainer(lists))
ui.Render(w, r, pages.ListsContainer(spaceID, cards))
}
func (h *SpaceHandler) GetListCardItems(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
listID := r.PathValue("listID")
if h.getListForSpace(w, spaceID, listID) == nil {
return
}
page := 1
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
page = p
}
items, totalPages, err := h.listService.GetItemsForListPaginated(listID, page)
if err != nil {
slog.Error("failed to get paginated items", "error", err, "list_id", listID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
ui.Render(w, r, shoppinglist.ListCardItems(spaceID, listID, items, page, totalPages))
}
func (h *SpaceHandler) buildListCards(spaceID string) ([]model.ListCardData, error) {
lists, err := h.listService.GetListsForSpace(spaceID)
if err != nil {
return nil, err
}
cards := make([]model.ListCardData, len(lists))
for i, list := range lists {
items, totalPages, err := h.listService.GetItemsForListPaginated(list.ID, 1)
if err != nil {
return nil, err
}
cards[i] = model.ListCardData{
List: list,
Items: items,
CurrentPage: 1,
TotalPages: totalPages,
}
}
return cards, nil
}

View file

@ -19,3 +19,10 @@ type ListItem struct {
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
type ListCardData struct {
List *ShoppingList
Items []*ListItem
CurrentPage int
TotalPages int
}

View file

@ -17,6 +17,8 @@ type ListItemRepository interface {
Create(item *model.ListItem) error
GetByID(id string) (*model.ListItem, error)
GetByListID(listID string) ([]*model.ListItem, error)
GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error)
CountByListID(listID string) (int, error)
Update(item *model.ListItem) error
Delete(id string) error
DeleteByListID(listID string) error
@ -56,6 +58,23 @@ func (r *listItemRepository) GetByListID(listID string) ([]*model.ListItem, erro
return items, nil
}
func (r *listItemRepository) GetByListIDPaginated(listID string, limit, offset int) ([]*model.ListItem, error) {
var items []*model.ListItem
query := `SELECT * FROM list_items WHERE list_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3;`
err := r.db.Select(&items, query, listID, limit, offset)
if err != nil {
return nil, err
}
return items, nil
}
func (r *listItemRepository) CountByListID(listID string) (int, error) {
var count int
query := `SELECT COUNT(*) FROM list_items WHERE list_id = $1;`
err := r.db.Get(&count, query, listID)
return count, err
}
func (r *listItemRepository) Update(item *model.ListItem) error {
item.UpdatedAt = time.Now()
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`

View file

@ -131,6 +131,10 @@ func SetupRoutes(a *app.App) http.Handler {
shoppingListItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(shoppingListItemsHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}/items", shoppingListItemsWithAccess)
cardItemsHandler := middleware.RequireAuth(space.GetListCardItems)
cardItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(cardItemsHandler)
mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}/card-items", cardItemsWithAccess)
listsComponentHandler := middleware.RequireAuth(space.GetLists)
listsComponentWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsComponentHandler)
mux.Handle("GET /app/spaces/{spaceID}/components/lists", listsComponentWithAccess)

View file

@ -140,6 +140,34 @@ func (s *ShoppingListService) GetItemsForList(listID string) ([]*model.ListItem,
return s.itemRepo.GetByListID(listID)
}
const ItemsPerCardPage = 5
func (s *ShoppingListService) GetItemsForListPaginated(listID string, page int) ([]*model.ListItem, int, error) {
total, err := s.itemRepo.CountByListID(listID)
if err != nil {
return nil, 0, err
}
totalPages := (total + ItemsPerCardPage - 1) / ItemsPerCardPage
if totalPages < 1 {
totalPages = 1
}
if page < 1 {
page = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * ItemsPerCardPage
items, err := s.itemRepo.GetByListIDPaginated(listID, ItemsPerCardPage, offset)
if err != nil {
return nil, 0, err
}
return items, totalPages, nil
}
func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*model.ListItem, error) {
name = strings.TrimSpace(name)
if name == "" {

View file

@ -2,12 +2,221 @@ package shoppinglist
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox"
"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/pagination"
)
// ListCard renders a full shopping list card with inline items, add form, and pagination.
templ ListCard(spaceID string, list *model.ShoppingList, items []*model.ListItem, currentPage, totalPages int) {
<div id={ "list-card-" + list.ID } class="border rounded-lg overflow-hidden flex flex-col">
<div class="p-4 border-b bg-muted/30">
@ListCardHeader(spaceID, list)
</div>
<div class="p-3 border-b">
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/lists/%s/items", spaceID, list.ID) }
hx-swap="none"
_={ fmt.Sprintf("on htmx:afterRequest if event.detail.successful reset() me then send refreshItems to #list-items-%s", list.ID) }
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "Add item...",
Class: "h-8 text-sm",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
Size: button.SizeSm,
}) {
@icon.Plus(icon.Props{Size: 16})
}
</form>
</div>
<div
id={ "list-items-" + list.ID }
hx-get={ fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=1", spaceID, list.ID) }
hx-trigger="refreshItems"
hx-swap="innerHTML"
>
@ListCardItems(spaceID, list.ID, items, currentPage, totalPages)
</div>
</div>
}
// ListCardHeader renders the card header with name display, edit form, and delete button.
templ ListCardHeader(spaceID string, list *model.ShoppingList) {
<div id={ "lch-" + list.ID } class="flex items-center justify-between gap-2">
<h3 class="font-semibold truncate">{ list.Name }</h3>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="text-muted-foreground hover:text-foreground p-1 rounded hover:bg-muted transition-colors"
_={ fmt.Sprintf("on click toggle .hidden on #lch-%s then toggle .hidden on #lche-%s then focus() the first <input/> in #lche-%s", list.ID, list.ID, list.ID) }
>
@icon.Pencil(icon.Props{Size: 14})
</button>
@dialog.Dialog(dialog.Props{ID: "del-list-" + list.ID}) {
@dialog.Trigger() {
<button
type="button"
class="text-muted-foreground hover:text-destructive p-1 rounded hover:bg-muted transition-colors"
>
@icon.Trash2(icon.Props{Size: 14})
</button>
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Shopping List
}
@dialog.Description() {
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items.
}
}
@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/lists/%s?from=card", spaceID, list.ID),
"hx-target": "#list-card-" + list.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
<form
id={ "lche-" + list.ID }
class="hidden flex items-center gap-2"
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s?from=card", spaceID, list.ID) }
hx-target={ "#lch-" + list.ID }
hx-swap="outerHTML"
_={ fmt.Sprintf("on htmx:afterRequest toggle .hidden on me then toggle .hidden on #lch-%s", list.ID) }
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: list.Name,
Class: "h-8 text-sm",
Attributes: templ.Attributes{
"required": "true",
},
})
<button type="submit" class="inline-flex items-center justify-center rounded-md text-sm font-medium h-8 px-3 bg-primary text-primary-foreground hover:bg-primary/90">
Save
</button>
<button
type="button"
class="inline-flex items-center justify-center rounded-md text-sm font-medium h-8 px-3 border hover:bg-muted"
_={ fmt.Sprintf("on click toggle .hidden on #lche-%s then toggle .hidden on #lch-%s", list.ID, list.ID) }
>
Cancel
</button>
</form>
}
// ListCardItems renders the paginated items section within a card.
templ ListCardItems(spaceID string, listID string, items []*model.ListItem, currentPage, totalPages int) {
if len(items) == 0 {
<p class="text-center text-muted-foreground p-6 text-sm">No items yet</p>
} else {
<div class="divide-y">
for _, item := range items {
@CardItemDetail(spaceID, item)
}
</div>
}
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage-1),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, pg),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=%d", spaceID, listID, currentPage+1),
"hx-target": "#list-items-" + listID,
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
// CardItemDetail renders an item within a card. Toggle is in-place, delete triggers a refresh.
templ CardItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 px-4 py-2">
@checkbox.Checkbox(checkbox.Props{
ID: "item-" + item.ID + "-checkbox",
Name: "is_checked",
Checked: item.IsChecked,
Attributes: templ.Attributes{
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s?from=card", spaceID, item.ListID, item.ID),
"hx-target": "#item-" + item.ID,
"hx-swap": "outerHTML",
},
})
<span class={ "text-sm flex-1", templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
<button
type="button"
class="text-muted-foreground hover:text-destructive p-1 rounded shrink-0"
hx-delete={ fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID) }
hx-swap="none"
_={ fmt.Sprintf("on htmx:afterRequest send refreshItems to #list-items-%s", item.ListID) }
>
@icon.X(icon.Props{Size: 14})
</button>
</div>
}
// ListNameHeader is used on the detail page for editing list name inline.
templ ListNameHeader(spaceID string, list *model.ShoppingList) {
<div id="list-name-header" class="flex items-center gap-2 group">
<h1 class="text-2xl font-bold">{ list.Name }</h1>
@ -15,7 +224,7 @@ templ ListNameHeader(spaceID string, list *model.ShoppingList) {
class="text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity"
_="on click toggle .hidden on #list-name-header then toggle .hidden on #list-name-edit then focus() the first <input/> in #list-name-edit"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"></path><path d="m15 5 4 4"></path></svg>
@icon.Pencil(icon.Props{Size: 16})
</button>
</div>
<form
@ -48,15 +257,7 @@ templ ListNameHeader(spaceID string, list *model.ShoppingList) {
</form>
}
templ ListItem(list *model.ShoppingList) {
<a href={ templ.URL(fmt.Sprintf("/app/spaces/%s/lists/%s", list.SpaceID, list.ID)) } class="block p-4 border rounded-lg hover:bg-muted transition-colors">
<div class="flex justify-between items-center">
<span class="font-medium">{ list.Name }</span>
// TODO: Add item count or other info
</div>
</a>
}
// ItemDetail renders an individual item row (used by the detail page and toggle responses).
templ ItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
@checkbox.Checkbox(checkbox.Props{

View file

@ -103,60 +103,13 @@ templ seo(props SEOProps) {
}
templ themeScript() {
<script nonce={ templ.GetNonce(ctx) }>
// Apply saved theme or system preference on load
if (localStorage.theme === 'dark' || (!localStorage.theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
// Theme toggle handler
document.addEventListener('click', (e) => {
if (e.target.closest('[data-theme-switcher]')) {
e.preventDefault();
const isDark = document.documentElement.classList.toggle('dark');
localStorage.theme = isDark ? 'dark' : 'light';
}
});
</script>
<script src="/assets/js/theme.js"></script>
}
templ smoothScrollScript() {
// Smooth scrolling for anchor links - works site-wide
<script nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
// Only prevent default for same-page anchors
const href = this.getAttribute('href');
if (href && href !== '#' && href.startsWith('#')) {
const target = document.querySelector(href);
if (target) {
e.preventDefault();
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}
});
});
});
</script>
<script src="/assets/js/smooth-scroll.js"></script>
}
templ htmxCSRFScript() {
// Configure HTMX to automatically send CSRF token with all requests
<script nonce={ templ.GetNonce(ctx) }>
document.addEventListener('DOMContentLoaded', function() {
// Listen for htmx requests and add CSRF token header
document.body.addEventListener('htmx:configRequest', function(event) {
// Get CSRF token from meta tag
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) {
// Add token as X-CSRF-Token header to all HTMX requests
event.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
}
});
});
</script>
<script src="/assets/js/htmx-csrf.js"></script>
}

View file

@ -9,7 +9,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
@layouts.Space("Shopping Lists", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
@ -40,14 +40,14 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
hx-trigger="sse:list_created, sse:list_deleted"
hx-swap="innerHTML"
>
@ListsContainer(lists)
@ListsContainer(space.ID, cards)
</div>
</div>
}
}
templ ListsContainer(lists []*model.ShoppingList) {
for _, list := range lists {
@shoppinglist.ListItem(list)
templ ListsContainer(spaceID string, cards []model.ListCardData) {
for _, card := range cards {
@shoppinglist.ListCard(spaceID, card.List, card.Items, card.CurrentPage, card.TotalPages)
}
}

View file

@ -72,14 +72,12 @@ templ SpaceOverviewPage(space *model.Space, lists []*model.ShoppingList, tags []
}
}
</ul>
if len(lists) > 5 {
<a
href={ templ.URL("/app/spaces/" + space.ID + "/lists") }
class="block text-sm text-muted-foreground hover:text-foreground transition-colors mt-2 px-3"
>
View all shopping lists
</a>
}
} else {
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
}