284 lines
8.8 KiB
Text
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(
|
|
"&", "&",
|
|
"<", "<",
|
|
">", ">",
|
|
"\"", """,
|
|
"'", "'",
|
|
)
|
|
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",
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|