budgit/internal/ui/pages/space_activity.templ
juancwu 2dac136049
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m31s
feat: savings allocations
2026-05-04 03:19:36 +00:00

284 lines
8.8 KiB
Text

package pages
import "encoding/json"
import "fmt"
import "sort"
import "strings"
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type SpaceActivityPageProps struct {
SpaceID string
SpaceName string
Rows []model.ActivityRow
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
templ SpaceActivityPage(props SpaceActivityPageProps) {
@layouts.AppWithBreadcrumb(
"Activity",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Activity"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-6">
<div>
<h1 class="text-3xl font-bold">Activity</h1>
<p class="text-muted-foreground mt-2">
An audit log of changes to { props.SpaceName }.
</p>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-0"}) {
if len(props.Rows) == 0 {
<p class="px-6 py-10 text-sm text-muted-foreground text-center">
No activity yet.
</p>
} else {
<ol class="divide-y">
for _, row := range props.Rows {
@accountActivityRow(props.SpaceID, txAccountIDFromRow(row), row)
}
</ol>
}
}
}
if props.TotalPages > 1 {
@activityPagination(props)
}
</div>
}
}
templ activityIcon(action model.SpaceAuditAction) {
switch action {
case model.SpaceAuditActionRenamed:
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
case model.SpaceAuditActionMemberInvited:
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionMemberJoined:
@icon.UserPlus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionMemberRemoved:
@icon.UserMinus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionInviteCancelled:
@icon.X(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAccountCreated:
@icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAccountRenamed:
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAccountDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
case model.SpaceAuditActionAllocationCreated:
@icon.Plus(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAllocationUpdated:
@icon.Pencil(icon.Props{Class: "size-4 text-muted-foreground"})
case model.SpaceAuditActionAllocationDeleted:
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
default:
@icon.History(icon.Props{Class: "size-4 text-muted-foreground"})
}
}
func actorLabel(log *model.SpaceAuditLogWithActor) string {
if log.ActorName != nil && *log.ActorName != "" {
return *log.ActorName
}
if log.ActorEmail != nil && *log.ActorEmail != "" {
return *log.ActorEmail
}
return "Someone"
}
func targetLabel(log *model.SpaceAuditLogWithActor) string {
if log.TargetUserName != nil && *log.TargetUserName != "" {
return *log.TargetUserName
}
if log.TargetUserEmail != nil && *log.TargetUserEmail != "" {
return *log.TargetUserEmail
}
if log.TargetEmail != nil && *log.TargetEmail != "" {
return *log.TargetEmail
}
return "a member"
}
// activityMessage returns a pre-escaped HTML string. Field values are escaped via
// templ.EscapeString; only the bold tags around them are intentional markup.
func activityMessage(log *model.SpaceAuditLogWithActor) string {
actor := bold(actorLabel(log))
target := bold(targetLabel(log))
switch log.Action {
case model.SpaceAuditActionRenamed:
var meta struct {
OldName string `json:"old_name"`
NewName string `json:"new_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
return fmt.Sprintf("%s renamed the space from %s to %s.",
actor, bold(meta.OldName), bold(meta.NewName))
case model.SpaceAuditActionDeleted:
var meta struct {
SpaceName string `json:"space_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.SpaceName
if name == "" {
name = "the space"
}
return fmt.Sprintf("%s deleted %s.", actor, bold(name))
case model.SpaceAuditActionMemberInvited:
return fmt.Sprintf("%s invited %s to join.", actor, target)
case model.SpaceAuditActionMemberJoined:
return fmt.Sprintf("%s joined the space.", target)
case model.SpaceAuditActionMemberRemoved:
return fmt.Sprintf("%s removed %s from the space.", actor, target)
case model.SpaceAuditActionInviteCancelled:
return fmt.Sprintf("%s cancelled the invitation for %s.", actor, target)
case model.SpaceAuditActionAccountCreated:
var meta struct {
AccountName string `json:"account_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.AccountName
if name == "" {
name = "an account"
}
return fmt.Sprintf("%s created the account %s.", actor, bold(name))
case model.SpaceAuditActionAccountRenamed:
var meta struct {
OldName string `json:"old_name"`
NewName string `json:"new_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
return fmt.Sprintf("%s renamed account %s to %s.",
actor, bold(meta.OldName), bold(meta.NewName))
case model.SpaceAuditActionAccountDeleted:
var meta struct {
AccountName string `json:"account_name"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.AccountName
if name == "" {
name = "an account"
}
return fmt.Sprintf("%s deleted the account %s.", actor, bold(name))
case model.SpaceAuditActionAllocationCreated:
var meta struct {
Name string `json:"name"`
Amount string `json:"amount"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.Name
if name == "" {
name = "a savings goal"
}
if meta.Amount != "" {
return fmt.Sprintf("%s created savings goal %s with $%s.", actor, bold(name), bold(meta.Amount))
}
return fmt.Sprintf("%s created savings goal %s.", actor, bold(name))
case model.SpaceAuditActionAllocationUpdated:
var meta struct {
Changes map[string]map[string]any `json:"changes"`
}
_ = json.Unmarshal(log.Metadata, &meta)
fields := make([]string, 0, len(meta.Changes))
for k := range meta.Changes {
fields = append(fields, k)
}
sort.Strings(fields)
if len(fields) == 0 {
return fmt.Sprintf("%s updated a savings goal.", actor)
}
return fmt.Sprintf("%s updated savings goal (%s).", actor, bold(strings.Join(fields, ", ")))
case model.SpaceAuditActionAllocationDeleted:
var meta struct {
Name string `json:"name"`
Amount string `json:"amount"`
}
_ = json.Unmarshal(log.Metadata, &meta)
name := meta.Name
if name == "" {
name = "a savings goal"
}
return fmt.Sprintf("%s deleted savings goal %s.", actor, bold(name))
default:
return fmt.Sprintf("%s performed %s.", actor, bold(string(log.Action)))
}
}
// txAccountIDFromRow extracts the account_id from a transaction audit row's metadata.
// All transaction audit entries (created/edited/deleted) embed account_id, so this
// gives the templ a stable handle for building links from the space-level feed.
func txAccountIDFromRow(row model.ActivityRow) string {
if row.TxLog == nil || len(row.TxLog.Metadata) == 0 {
return ""
}
var meta struct {
AccountID string `json:"account_id"`
}
_ = json.Unmarshal(row.TxLog.Metadata, &meta)
return meta.AccountID
}
func bold(s string) string {
return "<strong>" + templEscape(s) + "</strong>"
}
func templEscape(s string) string {
r := strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
"\"", "&#34;",
"'", "&#39;",
)
return r.Replace(s)
}
func activityPageURL(spaceID string, page int) string {
return fmt.Sprintf("%s?page=%d",
routeurl.URL("page.app.spaces.space.activity", "spaceID", spaceID), page)
}
templ activityPagination(props SpaceActivityPageProps) {
{{ p := pagination.CreatePagination(props.CurrentPage, props.TotalPages, 5) }}
@pagination.Pagination() {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Href: activityPageURL(props.SpaceID, p.CurrentPage-1),
Disabled: !p.HasPrevious,
Label: "Previous",
})
}
for _, page := range p.Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
Href: activityPageURL(props.SpaceID, page),
IsActive: page == p.CurrentPage,
}) {
{ fmt.Sprintf("%d", page) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Href: activityPageURL(props.SpaceID, p.CurrentPage+1),
Disabled: !p.HasNext,
Label: "Next",
})
}
}
}
}