feat: show shopping list items in cards
This commit is contained in:
parent
f596a923d9
commit
6c704828ce
14 changed files with 396 additions and 85 deletions
11
assets/js/htmx-csrf.js
Normal file
11
assets/js/htmx-csrf.js
Normal 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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
18
assets/js/smooth-scroll.js
Normal file
18
assets/js/smooth-scroll.js
Normal 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
13
assets/js/theme.js
Normal 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
3
go.mod
|
|
@ -4,7 +4,6 @@ go 1.25.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Oudwins/tailwind-merge-go v0.2.1
|
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/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
|
||||||
|
|
@ -22,6 +21,7 @@ require (
|
||||||
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
github.com/ClickHouse/ch-go v0.67.0 // indirect
|
||||||
github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // 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/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/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.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/segmentio/asm v1.2.0 // indirect
|
||||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.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/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect
|
||||||
github.com/vertica/vertica-sql-go v1.3.3 // indirect
|
github.com/vertica/vertica-sql-go v1.3.3 // indirect
|
||||||
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect
|
github.com/ydb-platform/ydb-go-genproto v0.0.0-20241112172322-ea1f63298f77 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -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/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 h1:trshEpGa8clF5cdI39iY4ZrZG8Z/QixyzEyUnA7feTM=
|
||||||
github.com/a-h/templ v0.3.960/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
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 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
|
||||||
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
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 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
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.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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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/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.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pkg/errors v0.9.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.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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
|
|
||||||
|
|
@ -125,14 +125,14 @@ func (h *SpaceHandler) ListsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
cards, err := h.buildListCards(spaceID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
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
|
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) {
|
func (h *SpaceHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -187,7 +187,11 @@ func (h *SpaceHandler) UpdateList(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, shoppinglist.ListNameHeader(spaceID, updatedList))
|
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) {
|
func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -205,7 +209,9 @@ func (h *SpaceHandler) DeleteList(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists")
|
if r.URL.Query().Get("from") != "card" {
|
||||||
|
w.Header().Set("HX-Redirect", "/app/spaces/"+spaceID+"/lists")
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,7 +299,11 @@ func (h *SpaceHandler) ToggleItem(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, shoppinglist.ItemDetail(spaceID, updatedItem))
|
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) {
|
func (h *SpaceHandler) DeleteItem(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -636,12 +646,58 @@ func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Reque
|
||||||
func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) {
|
func (h *SpaceHandler) GetLists(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
|
|
||||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
cards, err := h.buildListCards(spaceID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,10 @@ type ListItem struct {
|
||||||
CreatedAt time.Time `db:"created_at"`
|
CreatedAt time.Time `db:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListCardData struct {
|
||||||
|
List *ShoppingList
|
||||||
|
Items []*ListItem
|
||||||
|
CurrentPage int
|
||||||
|
TotalPages int
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ type ListItemRepository interface {
|
||||||
Create(item *model.ListItem) error
|
Create(item *model.ListItem) error
|
||||||
GetByID(id string) (*model.ListItem, error)
|
GetByID(id string) (*model.ListItem, error)
|
||||||
GetByListID(listID 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
|
Update(item *model.ListItem) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
DeleteByListID(listID string) error
|
DeleteByListID(listID string) error
|
||||||
|
|
@ -56,6 +58,23 @@ func (r *listItemRepository) GetByListID(listID string) ([]*model.ListItem, erro
|
||||||
return items, nil
|
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 {
|
func (r *listItemRepository) Update(item *model.ListItem) error {
|
||||||
item.UpdatedAt = time.Now()
|
item.UpdatedAt = time.Now()
|
||||||
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`
|
query := `UPDATE list_items SET name = $1, is_checked = $2, updated_at = $3 WHERE id = $4;`
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,10 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
shoppingListItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(shoppingListItemsHandler)
|
shoppingListItemsWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(shoppingListItemsHandler)
|
||||||
mux.Handle("GET /app/spaces/{spaceID}/lists/{listID}/items", shoppingListItemsWithAccess)
|
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)
|
listsComponentHandler := middleware.RequireAuth(space.GetLists)
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,34 @@ func (s *ShoppingListService) GetItemsForList(listID string) ([]*model.ListItem,
|
||||||
return s.itemRepo.GetByListID(listID)
|
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) {
|
func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*model.ListItem, error) {
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,221 @@ package shoppinglist
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"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/checkbox"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
"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/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) {
|
templ ListNameHeader(spaceID string, list *model.ShoppingList) {
|
||||||
<div id="list-name-header" class="flex items-center gap-2 group">
|
<div id="list-name-header" class="flex items-center gap-2 group">
|
||||||
<h1 class="text-2xl font-bold">{ list.Name }</h1>
|
<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"
|
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"
|
_="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form
|
<form
|
||||||
|
|
@ -48,15 +257,7 @@ templ ListNameHeader(spaceID string, list *model.ShoppingList) {
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ListItem(list *model.ShoppingList) {
|
// ItemDetail renders an individual item row (used by the detail page and toggle responses).
|
||||||
<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>
|
|
||||||
}
|
|
||||||
|
|
||||||
templ ItemDetail(spaceID string, item *model.ListItem) {
|
templ ItemDetail(spaceID string, item *model.ListItem) {
|
||||||
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
|
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
|
||||||
@checkbox.Checkbox(checkbox.Props{
|
@checkbox.Checkbox(checkbox.Props{
|
||||||
|
|
|
||||||
|
|
@ -103,60 +103,13 @@ templ seo(props SEOProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
templ themeScript() {
|
templ themeScript() {
|
||||||
<script nonce={ templ.GetNonce(ctx) }>
|
<script src="/assets/js/theme.js"></script>
|
||||||
// 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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ smoothScrollScript() {
|
templ smoothScrollScript() {
|
||||||
// Smooth scrolling for anchor links - works site-wide
|
<script src="/assets/js/smooth-scroll.js"></script>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
templ htmxCSRFScript() {
|
templ htmxCSRFScript() {
|
||||||
// Configure HTMX to automatically send CSRF token with all requests
|
<script src="/assets/js/htmx-csrf.js"></script>
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"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) {
|
@layouts.Space("Shopping Lists", 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">
|
||||||
|
|
@ -40,14 +40,14 @@ templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
|
||||||
hx-trigger="sse:list_created, sse:list_deleted"
|
hx-trigger="sse:list_created, sse:list_deleted"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
>
|
>
|
||||||
@ListsContainer(lists)
|
@ListsContainer(space.ID, cards)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
templ ListsContainer(lists []*model.ShoppingList) {
|
templ ListsContainer(spaceID string, cards []model.ListCardData) {
|
||||||
for _, list := range lists {
|
for _, card := range cards {
|
||||||
@shoppinglist.ListItem(list)
|
@shoppinglist.ListCard(spaceID, card.List, card.Items, card.CurrentPage, card.TotalPages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,14 +72,12 @@ templ SpaceOverviewPage(space *model.Space, lists []*model.ShoppingList, tags []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
if len(lists) > 5 {
|
<a
|
||||||
<a
|
href={ templ.URL("/app/spaces/" + space.ID + "/lists") }
|
||||||
href={ templ.URL("/app/spaces/" + space.ID + "/lists") }
|
class="block text-sm text-muted-foreground hover:text-foreground transition-colors mt-2 px-3"
|
||||||
class="block text-sm text-muted-foreground hover:text-foreground transition-colors mt-2 px-3"
|
>
|
||||||
>
|
View all shopping lists
|
||||||
View all shopping lists
|
</a>
|
||||||
</a>
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
|
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue