feat: share space

This commit is contained in:
juancwu 2026-05-03 19:05:15 +00:00
commit ed96faec8f
8 changed files with 624 additions and 5 deletions

View file

@ -0,0 +1,75 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
type JoinSpaceConfirmProps struct {
Token string
SpaceName string
InviterName string
InviteeEmail string
IsAuthed bool
AlreadyMember bool
}
templ JoinSpaceConfirm(props JoinSpaceConfirmProps) {
@layouts.Base(layouts.SEOProps{
Title: "Join " + props.SpaceName,
Description: "Accept invitation to join a space.",
Path: ctxkeys.URLPath(ctx),
}) {
<div class="min-h-screen flex items-center justify-center p-4 relative">
<div class="absolute top-4 right-4 z-10">
@blocks.ThemeSwitcher()
</div>
<div class="w-full max-w-md">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
You're invited
}
@card.Description() {
{ props.InviterName } has invited you to join <span class="font-medium text-foreground">{ props.SpaceName }</span>.
}
}
@card.Content(card.ContentProps{Class: "space-y-3"}) {
<p class="text-sm text-muted-foreground">
Invitation sent to <span class="text-foreground">{ props.InviteeEmail }</span>.
</p>
if props.AlreadyMember {
<p class="text-sm">You're already a member of this space.</p>
} else if !props.IsAuthed {
<p class="text-sm text-muted-foreground">
You'll need to sign in or create an account to continue.
</p>
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Href: "/",
}) {
Cancel
}
if !props.AlreadyMember {
<form action={ templ.SafeURL("/join/" + props.Token + "/accept") } method="POST" class="contents">
@csrf.Token()
@button.Button(button.Props{Type: button.TypeSubmit}) {
if props.IsAuthed {
Join Space
} else {
Continue to sign in
}
}
</form>
}
}
}
</div>
</div>
}
}

View file

@ -0,0 +1,190 @@
package pages
import "strconv"
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/forms"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
type SpaceMembersPageProps struct {
SpaceID string
SpaceName string
OwnerID string
CurrentUserID string
IsOwner bool
Members []*model.SpaceMemberWithProfile
PendingInvites []*model.SpaceInvitation
InviteForm forms.InviteMemberProps
}
templ SpaceMembersPage(props SpaceMembersPageProps) {
@layouts.AppWithBreadcrumb(
"Members",
spaceChildBreadcrumb(props.SpaceID, props.SpaceName, "Members"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
<div>
<h1 class="text-3xl font-bold">Members</h1>
<p class="text-muted-foreground mt-2">
Manage who has access to { props.SpaceName }.
</p>
</div>
if props.IsOwner {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Invite a member
}
@card.Description() {
Send an invitation by email.
}
}
@card.Content() {
@forms.InviteMember(props.InviteForm)
}
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
{ memberCountLabel(len(props.Members)) }
}
}
@card.Content() {
<ul class="divide-y">
for _, m := range props.Members {
@memberRow(props, m)
}
</ul>
}
}
if len(props.PendingInvites) > 0 {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Pending invitations
}
@card.Description() {
These people have been invited but haven't joined yet.
}
}
@card.Content() {
<ul class="divide-y">
for _, inv := range props.PendingInvites {
@pendingInviteRow(props, inv)
}
</ul>
}
}
}
</div>
}
}
func memberCountLabel(n int) string {
if n == 1 {
return "1 member"
}
return strconv.Itoa(n) + " members"
}
templ memberRow(props SpaceMembersPageProps, m *model.SpaceMemberWithProfile) {
{{
display := m.Email
if m.Name != nil && *m.Name != "" {
display = *m.Name
}
initial := "?"
if display != "" {
initial = strings.ToUpper(string(display[0]))
}
isOwner := m.UserID == props.OwnerID
isSelf := m.UserID == props.CurrentUserID
canRemove := props.IsOwner && !isOwner && !isSelf
}}
<li class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center font-medium">
{ initial }
</div>
<div class="min-w-0">
<p class="font-medium truncate">{ display }</p>
<p class="text-xs text-muted-foreground truncate">{ m.Email }</p>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs text-muted-foreground capitalize">{ string(m.Role) }</span>
if canRemove {
@dialog.Dialog() {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Attributes: templ.Attributes{"aria-label": "Remove member"},
}) {
@icon.Trash2(icon.Props{Class: "size-4 text-destructive"})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Remove { display }?
}
@dialog.Description() {
They'll lose access to this space immediately. You can re-invite them later.
}
}
@dialog.Footer(dialog.FooterProps{Class: "mt-2"}) {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
<form hx-post={ routeurl.URL("action.app.spaces.space.members.remove", "spaceID", props.SpaceID, "userID", m.UserID) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
}) {
Remove
}
</form>
}
}
}
}
</div>
</li>
}
templ pendingInviteRow(props SpaceMembersPageProps, inv *model.SpaceInvitation) {
<li class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div class="min-w-0">
<p class="font-medium truncate">{ inv.Email }</p>
<p class="text-xs text-muted-foreground">Invited { inv.CreatedAt.Format("Jan 2, 2006") } · expires { inv.ExpiresAt.Format("Jan 2, 2006") }</p>
</div>
</div>
if props.IsOwner {
<form hx-post={ routeurl.URL("action.app.spaces.space.invitations.cancel", "spaceID", props.SpaceID, "token", inv.Token) }>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantGhost,
Size: button.SizeSm,
}) {
Cancel invitation
}
</form>
}
</li>
}

View file

@ -64,6 +64,16 @@ templ spaceSpecificSidebarContent(spaceID string) {
<span>Space Overview</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.members", "spaceID", spaceID),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.spaces.space.members", "spaceID", spaceID),
Tooltip: "Members",
}) {
@icon.Users()
<span>Members</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.spaces.space.settings", "spaceID", spaceID),