402 lines
12 KiB
Text
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>
|
|
}
|
|
}
|