diff --git a/assets/css/input.css b/assets/css/input.css index 09f1714..4ef8bc9 100644 --- a/assets/css/input.css +++ b/assets/css/input.css @@ -47,6 +47,7 @@ --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); + --color-success: var(--success); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); @@ -89,6 +90,7 @@ --accent: oklch(0.97 0 0); --accent-foreground: oklch(0.205 0 0); --destructive: oklch(0.577 0.245 27.325); + --success: oklch(62.7% 0.194 149.214); --border: oklch(0.922 0 0); --input: oklch(0.922 0 0); --ring: oklch(0.708 0 0); @@ -126,6 +128,7 @@ --accent: oklch(0.269 0 0); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); + --success: oklch(79.2% 0.209 151.711); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.556 0 0); diff --git a/internal/handler/space.go b/internal/handler/space.go index e785756..865a691 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -3,11 +3,13 @@ package handler import ( "log/slog" "net/http" + "strings" "git.juancwu.dev/juancwu/budgit/internal/ctxkeys" "git.juancwu.dev/juancwu/budgit/internal/service" "git.juancwu.dev/juancwu/budgit/internal/ui" "git.juancwu.dev/juancwu/budgit/internal/ui/blocks" + "git.juancwu.dev/juancwu/budgit/internal/ui/forms" "git.juancwu.dev/juancwu/budgit/internal/ui/pages" "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)) } + +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)) +} diff --git a/internal/middleware/space.go b/internal/middleware/space.go index a3a8394..fb47d95 100644 --- a/internal/middleware/space.go +++ b/internal/middleware/space.go @@ -10,8 +10,8 @@ import ( // RequireSpaceAccess validates that a user is a member of the space they are trying to access. // It expects a URL parameter named "spaceID". -func RequireSpaceAccess(spaceService *service.SpaceService) func(http.HandlerFunc) http.HandlerFunc { - return func(next http.HandlerFunc) http.HandlerFunc { +func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.HandlerFunc { + return func(next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { user := ctxkeys.User(r.Context()) if user == nil { diff --git a/internal/repository/space.go b/internal/repository/space.go index 70b473d..94a0294 100644 --- a/internal/repository/space.go +++ b/internal/repository/space.go @@ -21,6 +21,7 @@ type SpaceRepository interface { RemoveMember(spaceID, userID string) error IsMember(spaceID, userID string) (bool, error) GetMembers(spaceID string) ([]*model.SpaceMemberWithProfile, error) + GetMember(spaceID string, userID string) (*model.SpaceMember, error) UpdateName(spaceID, name string) error GetMemberCount(spaceID string) (int, error) @@ -125,6 +126,18 @@ func (r *spaceRepository) GetMembers(spaceID string) ([]*model.SpaceMemberWithPr 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 { query := `UPDATE spaces SET name = $1, updated_at = $2 WHERE id = $3;` _, err := r.db.Exec(query, name, time.Now(), spaceID) diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 96868d2..ba4823d 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -85,6 +85,13 @@ func SetupRoutes(a *app.App) http.Handler { g.SubGroup("/spaces", func(g *router.Group) { 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) { diff --git a/internal/service/space.go b/internal/service/space.go index 36def42..76013c1 100644 --- a/internal/service/space.go +++ b/internal/service/space.go @@ -102,3 +102,18 @@ func (s *SpaceService) GetMemberCount(spaceID string) (int, error) { } 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 +} diff --git a/internal/ui/forms/create_space.templ b/internal/ui/forms/create_space.templ new file mode 100644 index 0000000..de87b00 --- /dev/null +++ b/internal/ui/forms/create_space.templ @@ -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) { +
+} diff --git a/internal/ui/forms/create_space_success.templ b/internal/ui/forms/create_space_success.templ new file mode 100644 index 0000000..5e2487c --- /dev/null +++ b/internal/ui/forms/create_space_success.templ @@ -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) { +Space successfully created!
+ } + @card.Footer() { + @button.Button(button.Props{Class: "w-full", Href: routeurl.URL("page.app.spaces.space.overview", "spaceID", spaceID)}) { + Start tracking expenses + } + } + } +This is where you or your group can track expenses.
+