add shopping list and tag management

This commit is contained in:
juancwu 2026-01-14 18:19:13 +00:00
commit b7905ddded
19 changed files with 1253 additions and 11 deletions

View file

@ -0,0 +1,38 @@
package shoppinglist
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
)
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>
}
templ ItemDetail(spaceID string, item *model.ListItem) {
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
<input
type="checkbox"
name="is_checked"
checked?={ item.IsChecked }
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID) }
hx-target={ "#item-" + item.ID }
hx-swap="outerHTML"
class="checkbox"
/>
<span class={ templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
<button
hx-delete={ fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID) }
hx-target={ "#item-" + item.ID }
hx-swap="outerHTML"
class="ml-auto btn btn-xs btn-ghost"
>
&times;
</button>
</div>
}

View file

@ -0,0 +1,23 @@
package tag
import "git.juancwu.dev/juancwu/budgit/internal/model"
templ Tag(tag *model.Tag) {
<div
id={ "tag-" + tag.ID }
class="flex items-center gap-2 rounded-full border px-3 py-1 text-sm"
>
if tag.Color != nil {
<span class="size-3 rounded-full" style={ "background-color: " + *tag.Color }></span>
}
<span>{ tag.Name }</span>
<button
hx-delete={ "/app/spaces/" + tag.SpaceID + "/tags/" + tag.ID }
hx-target={ "#tag-" + tag.ID }
hx-swap="outerHTML"
class="ml-auto text-muted-foreground hover:text-destructive"
>
&times;
</button>
</div>
}

View file

@ -0,0 +1,125 @@
package layouts
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
)
templ Space(title string, space *model.Space) {
{{ cfg := ctxkeys.Config(ctx) }}
@Base(SEOProps{
Title: title,
Description: "Space Dashboard",
Path: ctxkeys.URLPath(ctx),
}) {
@sidebar.Layout() {
@sidebar.Sidebar() {
@sidebar.Header() {
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Size: sidebar.MenuButtonSizeLg,
Href: "/app/dashboard",
}) {
@icon.LayoutDashboard()
<div class="flex flex-col">
<span class="text-sm font-bold">{ cfg.AppName }</span>
<span class="text-xs text-muted-foreground">Back to Home</span>
</div>
}
}
}
}
@sidebar.Content() {
@sidebar.Group() {
@sidebar.GroupLabel() {
{ space.Name }
}
@sidebar.Menu() {
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID,
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
Tooltip: "Space Dashboard",
}) {
@icon.House(icon.Props{Class: "size-4"})
<span>Overview</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/lists",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/lists",
Tooltip: "Shopping Lists",
}) {
@icon.ShoppingCart(icon.Props{Class: "size-4"})
<span>Shopping Lists</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/tags",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/tags",
Tooltip: "Tags",
}) {
@icon.Tag(icon.Props{Class: "size-4"})
<span>Tags</span>
}
}
}
}
}
@sidebar.Footer() {
// Re-using the same dropdown from app layout
{{ user := ctxkeys.User(ctx) }}
{{ profile := ctxkeys.Profile(ctx) }}
if user != nil && profile != nil {
@AppSidebarDropdown(user, profile)
}
}
}
@sidebar.Inset() {
// Top Navigation Bar
<header class="sticky top-0 z-10 border-b bg-background">
<div class="flex h-14 items-center px-6">
<div class="flex items-center gap-4">
@sidebar.Trigger()
@breadcrumb.Breadcrumb() {
@breadcrumb.List() {
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/dashboard"}) {
Home
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
{ space.Name }
}
}
@breadcrumb.Separator()
@breadcrumb.Item() {
@breadcrumb.Page() {
{ title }
}
}
}
}
</div>
<div class="ml-auto flex items-center gap-4">
@blocks.ThemeSwitcher()
</div>
</div>
</header>
// App Content
<main class="flex-1 p-6">
{ children... }
</main>
}
}
}
}

View file

@ -0,0 +1,42 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceDashboardPage(space *model.Space, lists []*model.ShoppingList, tags []*model.Tag) {
@layouts.Space("Dashboard", space) {
<div class="space-y-4">
<h1 class="text-2xl font-bold">Welcome to { space.Name }!</h1>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
// Shopping Lists section
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-2">Shopping Lists</h2>
if len(lists) > 0 {
<ul>
for _, list := range lists {
<li>{ list.Name }</li>
}
</ul>
} else {
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
}
</div>
// Tags section
<div class="border rounded-lg p-4">
<h2 class="text-lg font-semibold mb-2">Tags</h2>
if len(tags) > 0 {
<div class="flex flex-wrap gap-2">
for _, tag := range tags {
<span class="bg-secondary text-secondary-foreground rounded-full px-3 py-1 text-sm">{ tag.Name }</span>
}
</div>
} else {
<p class="text-sm text-muted-foreground">No tags yet.</p>
}
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,45 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []*model.ListItem) {
@layouts.Space(list.Name, space) {
<div class="space-y-4">
<h1 class="text-2xl font-bold">{ list.Name }</h1>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
hx-target="#items-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New item...",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Add Item
}
</form>
<div id="items-container" class="border rounded-lg">
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)
}
}
</div>
</div>
}
}

View file

@ -0,0 +1,43 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListsPage(space *model.Space, lists []*model.ShoppingList) {
@layouts.Space("Shopping Lists", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Shopping Lists</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists" }
hx-target="#lists-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New list name...",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Create
}
</form>
<div id="lists-container" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
for _, list := range lists {
@shoppinglist.ListItem(list)
}
</div>
</div>
}
}

View file

@ -0,0 +1,48 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
@layouts.Space("Tags", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Tags</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/tags" }
hx-target="#tags-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New tag name...",
})
@input.Input(input.Props{
Type: "color",
Name: "color",
Class: "w-14",
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Create
}
</form>
<div id="tags-container" class="flex flex-wrap gap-2">
for _, t := range tags {
@tag.Tag(t)
}
</div>
</div>
}
}