feat: dedicated settings page for space

This commit is contained in:
juancwu 2026-02-07 19:16:16 -05:00
commit 1b5c57704e
No known key found for this signature in database
9 changed files with 516 additions and 0 deletions

View file

@ -80,6 +80,16 @@ templ Space(title string, space *model.Space) {
<span>Tags</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: "/app/spaces/" + space.ID + "/settings",
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings",
Tooltip: "Settings",
}) {
@icon.Settings(icon.Props{Class: "size-4"})
<span>Settings</span>
}
}
}
}
}

View file

@ -0,0 +1,263 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"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/layouts"
)
templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) {
@layouts.Space("Settings", space) {
<div class="space-y-6 max-w-2xl">
// Space Name Section
@card.Card() {
@card.Header() {
@card.Title() {
Space Name
}
@card.Description() {
if isOwner {
Update the name of this space.
} else {
The name of this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
hx-swap="none"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: space.Name,
Attributes: templ.Attributes{
"autocomplete": "off",
"required": true,
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
Save
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Members Section
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Users(icon.Props{Class: "size-5"})
Members
</div>
}
@card.Description() {
People who have access to this space.
}
}
@card.Content() {
<div class="divide-y" id="members-list">
for _, member := range members {
@MemberRow(space.ID, member, isOwner, currentUserID)
}
</div>
}
}
// Invitations Section (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Mail(icon.Props{Class: "size-5"})
Invitations
</div>
}
@card.Description() {
Invite new members and manage pending invitations.
}
}
@card.Content() {
<div class="space-y-4">
<form
hx-post={ "/app/spaces/" + space.ID + "/invites" }
hx-swap="none"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "email",
Placeholder: "Email address...",
Attributes: templ.Attributes{
"type": "email",
"autocomplete": "off",
"required": true,
},
})
@button.Button(button.Props{
Type: button.TypeSubmit,
}) {
@icon.UserPlus(icon.Props{Class: "size-4"})
Invite
}
</form>
<div
id="pending-invites"
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
hx-trigger="refreshInvites from:body"
hx-swap="innerHTML"
>
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(space.ID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
</div>
</div>
}
}
}
</div>
@dialog.Script()
}
}
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{ string([]rune(member.Name)[0]) }
</div>
<div>
<p class="text-sm font-medium">{ member.Name }</p>
<p class="text-xs text-muted-foreground">{ member.Email }</p>
</div>
</div>
<div class="flex items-center gap-2">
if member.Role == model.RoleOwner {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
@icon.Crown(icon.Props{Class: "size-3"})
Owner
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Member
}
}
if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner {
{{ dialogID := "remove-member-dialog-" + member.UserID }}
@dialog.Dialog(dialog.Props{ID: dialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
}) {
@icon.UserMinus(icon.Props{Class: "size-4 text-destructive"})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Remove member
}
@dialog.Description() {
Are you sure you want to remove { member.Name } from this space? They will lose access immediately.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID,
"hx-target": "#member-" + member.UserID,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
Remove
}
}
}
}
}
}
</div>
</div>
}
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div>
<p class="text-sm font-medium">{ invite.Email }</p>
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
</div>
</div>
<div class="flex items-center gap-2">
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
Pending
}
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token,
"hx-target": "#invite-" + invite.Token,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
@icon.X(icon.Props{Class: "size-4 text-destructive"})
}
</div>
</div>
}
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(spaceID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
}