feat: create space

This commit is contained in:
juancwu 2026-04-11 21:46:52 +00:00
commit 775177cba1
11 changed files with 200 additions and 3 deletions

View file

@ -47,6 +47,7 @@
--color-accent: var(--accent); --color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-success: var(--success);
--color-border: var(--border); --color-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
@ -89,6 +90,7 @@
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--success: oklch(62.7% 0.194 149.214);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
@ -126,6 +128,7 @@
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--success: oklch(79.2% 0.209 151.711);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);

View file

@ -3,11 +3,13 @@ package handler
import ( import (
"log/slog" "log/slog"
"net/http" "net/http"
"strings"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/service"
"git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks" "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
"git.juancwu.dev/juancwu/budgit/internal/ui/forms"
"git.juancwu.dev/juancwu/budgit/internal/ui/pages" "git.juancwu.dev/juancwu/budgit/internal/ui/pages"
"github.com/shopspring/decimal" "github.com/shopspring/decimal"
) )
@ -66,3 +68,52 @@ func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.Spaces(cards)) ui.Render(w, r, pages.Spaces(cards))
} }
func (h *spaceHandler) CreateSpacePage(w http.ResponseWriter, r *http.Request) {
ui.Render(w, r, pages.CreateSpace())
}
func (h *spaceHandler) HandleCreateSpace(w http.ResponseWriter, r *http.Request) {
spaceName := strings.TrimSpace(r.FormValue("name"))
if spaceName == "" {
ui.Render(w, r, forms.CreateSpace("Space name can't be empty.", spaceName))
return
}
user := ctxkeys.User(r.Context())
isNameAvailable, err := h.spaceService.IsNameAvailable(spaceName, user.ID)
if err != nil {
slog.Error("failed to create new space", "error", err, "user_id", user.ID)
ui.Render(w, r, forms.CreateSpace("Something went wrong. Please try again later.", spaceName))
return
}
if !isNameAvailable {
ui.Render(w, r, forms.CreateSpace("Space name is not available. Please use another name.", spaceName))
return
}
sp, err := h.spaceService.CreateSpace(spaceName, user.ID)
if err != nil {
slog.Error("failed to create new space", "error", err, "user_id", user.ID)
ui.Render(w, r, forms.CreateSpace("Something went wrong. Please try again later.", spaceName))
return
}
ui.Render(w, r, forms.CreateSpaceSuccess(sp.ID))
}
func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
space, err := h.spaceService.GetSpace(spaceID)
if err != nil {
slog.Error("failed to fetch space data", "error", err, "spaceID", spaceID)
ui.Render(w, r, pages.NotFound())
return
}
ui.Render(w, r, pages.SpaceOverview(space.Name))
}

View file

@ -10,8 +10,8 @@ import (
// RequireSpaceAccess validates that a user is a member of the space they are trying to access. // RequireSpaceAccess validates that a user is a member of the space they are trying to access.
// It expects a URL parameter named "spaceID". // It expects a URL parameter named "spaceID".
func RequireSpaceAccess(spaceService *service.SpaceService) func(http.HandlerFunc) http.HandlerFunc { func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.HandlerFunc {
return func(next http.HandlerFunc) http.HandlerFunc { return func(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
user := ctxkeys.User(r.Context()) user := ctxkeys.User(r.Context())
if user == nil { if user == nil {

View file

@ -21,6 +21,7 @@ type SpaceRepository interface {
RemoveMember(spaceID, userID string) error RemoveMember(spaceID, userID string) error
IsMember(spaceID, userID string) (bool, error) IsMember(spaceID, userID string) (bool, error)
GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error)
GetMember(spaceID string, userID string) (*model.SpaceMember, error)
UpdateName(spaceID, name string) error UpdateName(spaceID, name string) error
GetMemberCount(spaceID string) (int, error) GetMemberCount(spaceID string) (int, error)
@ -125,6 +126,18 @@ func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithPr
return members, err return members, err
} }
func (r *spaceRepository) GetMember(spaceID, userID string) (*model.SpaceMember, error) {
query := `SELECT * FROM space_members WHERE space_id = $1 AND user_id = $2;`
var member model.SpaceMember
err := r.db.Get(&member, query, spaceID, userID)
if err != nil {
return nil, err
}
return &member, nil
}
func (r *spaceRepository) UpdateName(spaceID, name string) error { func (r *spaceRepository) UpdateName(spaceID, name string) error {
query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;` query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;`
_, err := r.db.Exec(query, name, time.Now(), spaceID) _, err := r.db.Exec(query, name, time.Now(), spaceID)

View file

@ -85,6 +85,13 @@ func SetupRoutes(a *app.App) http.Handler {
g.SubGroup("/spaces", func(g *router.Group) { g.SubGroup("/spaces", func(g *router.Group) {
g.Get("", spaceH.SpacesPage).Name("page.app.spaces") g.Get("", spaceH.SpacesPage).Name("page.app.spaces")
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
g.Post("/create", spaceH.HandleCreateSpace).Name("action.app.spaces.create")
g.SubGroup("/{spaceID}", func(g *router.Group) {
spaceAccessMw := middleware.RequireSpaceAccess(a.SpaceService)
g.Use(spaceAccessMw)
g.Get("/overview", spaceH.SpaceOverviewPage).Name("page.app.spaces.space.overview")
})
}) })
g.SubGroup("/settings", func(g *router.Group) { g.SubGroup("/settings", func(g *router.Group) {

View file

@ -102,3 +102,18 @@ func (s *SpaceService) GetMemberCount(spaceID string) (int, error) {
} }
return count, nil return count, nil
} }
func (s *SpaceService) IsNameAvailable(name string, userID string) (bool, error) {
spaces, err := s.GetSpacesForUser(userID)
if err != nil {
return false, fmt.Errorf("failed to get spaces to check name availability: %w", err)
}
for _, sp := range spaces {
if sp.Name == name {
return false, nil
}
}
return true, nil
}

View file

@ -0,0 +1,48 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
templ CreateSpace(errMsg string, spaceName string) {
<form hx-post={ routeurl.URL("action.app.spaces.create") }>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4"}) {
@form.Item() {
@form.Label(form.LabelProps{
For: "space-name",
}) {
Space Name
}
@input.Input(input.Props{
ID: "space-name",
Type: input.TypeText,
Placeholder: "My Expenses",
Class: "rounded-sm",
Name: "name",
Value: spaceName,
HasError: errMsg != "",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
if errMsg != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ errMsg }
}
}
@form.Description() {
You can always rename your space later in space settings.
}
}
}
@card.Footer() {
@button.Button(button.Props{Class: "w-full", Type: button.TypeSubmit}) {
Create
}
}
}
</form>
}

View file

@ -0,0 +1,20 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
templ CreateSpaceSuccess(spaceID string) {
<div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4"}) {
<p class="text-xl text-success">Space successfully created!</p>
}
@card.Footer() {
@button.Button(button.Props{Class: "w-full", Href: routeurl.URL("page.app.spaces.space.overview", "spaceID", spaceID)}) {
Start tracking expenses
}
}
}
</div>
}

View file

@ -0,0 +1,31 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
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/forms"
templ CreateSpace() {
@layouts.Base(layouts.SEOProps{
Title: "Create Space",
Description: "Create space to manage and track expenses in a collaborative manner.",
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-2xl md:grid md:grid-cols-2 md:gap-4">
<div class="space-y-2">
<h1 class="text-2xl">
Create Space
</h1>
<p class="text-sm text-muted-foreground">This is where you or your group can track expenses.</p>
</div>
<div class="mt-4 md:mt-0">
@forms.CreateSpace("", "")
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,9 @@
package pages
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
templ SpaceOverview(spaceName string) {
@layouts.App("Space Overview") {
<div>space overview: { spaceName }</div>
}
}

View file

@ -17,7 +17,7 @@ templ Spaces(spaces []blocks.SpaceCardInfo) {
<div> <div>
@button.Button(button.Props{ @button.Button(button.Props{
Class: "flex gap-2 items-center", Class: "flex gap-2 items-center",
Href: routeurl.URL("page.app.create-space"), Href: routeurl.URL("page.app.spaces.create"),
}) { }) {
@icon.Plus() @icon.Plus()
Create space Create space