budgit/internal/ui/pages/app_space_settings.templ
juancwu b5295d1c73
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m34s
fix: delete space form issues
2026-03-14 19:20:43 -04:00

402 lines
12 KiB
Text

package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"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/form"
"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/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
"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.Submit() {
Save
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Timezone Section
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
if isOwner {
Set a timezone for this space. Recurring expenses and reports will use this timezone.
} else {
The timezone used for recurring expenses and reports in this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/timezone" }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "space-timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: space.Timezone != nil && tz.Value == *space.Timezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Save Timezone
}
</form>
} else {
if space.Timezone != nil && *space.Timezone != "" {
<p class="text-sm">{ *space.Timezone }</p>
} else {
<p class="text-sm text-muted-foreground">Not set (uses your timezone)</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.Submit() {
@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>
}
}
}
// Danger Zone (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2 text-destructive">
@icon.TriangleAlert(icon.Props{Class: "size-5"})
Danger Zone
</div>
}
@card.Description() {
Irreversible and destructive actions.
}
}
@card.Content() {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Delete this space</p>
<p class="text-sm text-muted-foreground">Once deleted, all data in this space will be permanently removed.</p>
</div>
{{ deleteDialogID := "delete-space-dialog-" + space.ID }}
@dialog.Dialog(dialog.Props{ID: deleteDialogID, DisableClickAway: true}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
}) {
Delete Space
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete space
}
@dialog.Description() {
This action is permanent and cannot be undone. All data including expenses, budgets, shopping lists, and members will be permanently deleted.
}
}
<form
hx-delete={ "/app/spaces/" + space.ID }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "confirmation_name"}) {
Type <span class="font-semibold">{ space.Name }</span> to confirm
}
@input.Input(input.Props{
Name: "confirmation_name",
Placeholder: space.Name,
Attributes: templ.Attributes{
"id": "confirmation_name",
"autocomplete": "off",
},
})
</div>
<div class="flex justify-end gap-2">
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeSubmit,
Attributes: templ.Attributes{
"id": "delete-space-confirm",
},
}) {
Delete Space
}
</div>
</form>
}
}
</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>
}
}