feat: create space
This commit is contained in:
parent
8e952455cd
commit
775177cba1
11 changed files with 200 additions and 3 deletions
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
48
internal/ui/forms/create_space.templ
Normal file
48
internal/ui/forms/create_space.templ
Normal 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>
|
||||||
|
}
|
||||||
20
internal/ui/forms/create_space_success.templ
Normal file
20
internal/ui/forms/create_space_success.templ
Normal 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>
|
||||||
|
}
|
||||||
31
internal/ui/pages/create_space.templ
Normal file
31
internal/ui/pages/create_space.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
internal/ui/pages/page_app_spaces_space_overview.templ
Normal file
9
internal/ui/pages/page_app_spaces_space_overview.templ
Normal 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>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue