feat: better permalinks for spaces
This commit is contained in:
parent
0f9c122608
commit
7f9b38c33b
10 changed files with 113 additions and 22 deletions
|
|
@ -8,7 +8,8 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
UserKey string = "user"
|
||||
UserKey string = "user"
|
||||
SpaceKey string = "space"
|
||||
|
||||
URLPathKey string = "url_path"
|
||||
ConfigKey string = "config"
|
||||
|
|
@ -25,6 +26,15 @@ func WithUser(ctx context.Context, user *model.User) context.Context {
|
|||
return context.WithValue(ctx, UserKey, user)
|
||||
}
|
||||
|
||||
func Space(ctx context.Context) *model.Space {
|
||||
sp, _ := ctx.Value(SpaceKey).(*model.Space)
|
||||
return sp
|
||||
}
|
||||
|
||||
func WithSpace(ctx context.Context, sp *model.Space) context.Context {
|
||||
return context.WithValue(ctx, SpaceKey, sp)
|
||||
}
|
||||
|
||||
|
||||
func URLPath(ctx context.Context) string {
|
||||
path, _ := ctx.Value(URLPathKey).(string)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/misc/slug"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
|
||||
|
|
@ -61,6 +62,7 @@ func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) {
|
|||
cards = append(cards, blocks.SpaceCardInfo{
|
||||
ID: sp.ID,
|
||||
Name: sp.Name,
|
||||
Slug: slug.Make(sp.Name),
|
||||
MemberCount: memberCount,
|
||||
TotalBalance: totalBalance,
|
||||
})
|
||||
|
|
@ -102,18 +104,14 @@ func (h *spaceHandler) HandleCreateSpace(w http.ResponseWriter, r *http.Request)
|
|||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, forms.CreateSpaceSuccess(sp.ID))
|
||||
ui.Render(w, r, forms.CreateSpaceSuccess(slug.Make(sp.Name)))
|
||||
}
|
||||
|
||||
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)
|
||||
space := ctxkeys.Space(r.Context())
|
||||
if space == nil {
|
||||
ui.Render(w, r, pages.NotFound())
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceOverview(space.Name))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,16 @@ import (
|
|||
"net/http"
|
||||
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/misc/slug"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||
)
|
||||
|
||||
// RequireSpaceAccess validates that a user is a member of the space they are trying to access.
|
||||
// It expects a URL parameter named "spaceID".
|
||||
// RequireSpaceAccess resolves the {spaceName} path parameter against the
|
||||
// current user's spaces. If the user is not a member of a space with that
|
||||
// name, the request is redirected to /forbidden. On success the matched
|
||||
// space is stashed on the request context via ctxkeys.WithSpace so
|
||||
// downstream handlers can read it without another DB lookup.
|
||||
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) {
|
||||
|
|
@ -20,26 +25,43 @@ func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) h
|
|||
return
|
||||
}
|
||||
|
||||
spaceID := r.PathValue("spaceID")
|
||||
if spaceID == "" {
|
||||
slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceID} path parameter")
|
||||
spaceSlug := r.PathValue("spaceSlug")
|
||||
if spaceSlug == "" {
|
||||
slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceSlug} path parameter")
|
||||
notfound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
isMember, err := spaceService.IsMember(user.ID, spaceID)
|
||||
spaces, err := spaceService.GetSpacesForUser(user.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to check space membership", "error", err, "user_id", user.ID, "space_id", spaceID)
|
||||
slog.Error("failed to load user spaces", "error", err, "user_id", user.ID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if !isMember {
|
||||
var matched *model.Space
|
||||
for _, sp := range spaces {
|
||||
if slug.Make(sp.Name) != spaceSlug {
|
||||
continue
|
||||
}
|
||||
if matched != nil {
|
||||
slog.Warn("ambiguous space slug for user; using first match",
|
||||
"user_id", user.ID,
|
||||
"space_slug", spaceSlug,
|
||||
"first", matched.ID,
|
||||
"second", sp.ID,
|
||||
)
|
||||
break
|
||||
}
|
||||
matched = sp
|
||||
}
|
||||
if matched == nil {
|
||||
redirect(w, r, "/forbidden", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
ctx := ctxkeys.WithSpace(r.Context(), matched)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
internal/misc/slug/slug.go
Normal file
37
internal/misc/slug/slug.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Package slug produces URL-friendly slugs from arbitrary strings.
|
||||
package slug
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// Make converts s into a lowercase, hyphen-separated slug. Runs of
|
||||
// non-alphanumeric characters collapse to a single "-", and leading or
|
||||
// trailing hyphens are trimmed. Non-ASCII letters/digits are preserved
|
||||
// (lowercased); everything else becomes a separator.
|
||||
//
|
||||
// Make("John's Space") // "johns-space"
|
||||
// Make("Savings & Debt") // "savings-debt"
|
||||
// Make(" --Hello-- ") // "hello"
|
||||
func Make(s string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(s))
|
||||
prevHyphen := true // suppresses leading hyphens
|
||||
for _, r := range s {
|
||||
switch {
|
||||
case unicode.IsLetter(r) || unicode.IsDigit(r):
|
||||
b.WriteRune(unicode.ToLower(r))
|
||||
prevHyphen = false
|
||||
case r == '\'' || r == '`' || r == '"' || r == '’':
|
||||
// Intra-word quote marks are stripped, not turned into separators,
|
||||
// so "John's" → "johns" rather than "john-s".
|
||||
default:
|
||||
if !prevHyphen {
|
||||
b.WriteByte('-')
|
||||
prevHyphen = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "-")
|
||||
}
|
||||
21
internal/misc/slug/slug_test.go
Normal file
21
internal/misc/slug/slug_test.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package slug
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMake(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"John's Space": "johns-space",
|
||||
"Savings & Debt": "savings-debt",
|
||||
" --Hello-- ": "hello",
|
||||
"Ada Lovelace": "ada-lovelace",
|
||||
"Already-slug": "already-slug",
|
||||
"": "",
|
||||
"!!!": "",
|
||||
"Foo Bar": "foo-bar",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Make(in); got != want {
|
||||
t.Errorf("Make(%q) = %q; want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,7 +87,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
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) {
|
||||
g.SubGroup("/{spaceSlug}", func(g *router.Group) {
|
||||
spaceAccessMw := middleware.RequireSpaceAccess(a.SpaceService)
|
||||
g.Use(spaceAccessMw)
|
||||
g.Get("/overview", spaceH.SpaceOverviewPage).Name("page.app.spaces.space.overview")
|
||||
|
|
|
|||
|
|
@ -229,6 +229,7 @@ func TestURL_ResolvesNamedRoute(t *testing.T) {
|
|||
assert.Equal(t, "/privacy", routeurl.URL("page.public.privacy"))
|
||||
assert.Equal(t, "/app/spaces", routeurl.URL("page.app.spaces"))
|
||||
assert.Equal(t, "/join/abc123", routeurl.URL("page.public.join-space", "token", "abc123"))
|
||||
assert.Equal(t, "/app/spaces/johns-space/overview", routeurl.URL("page.app.spaces.space.overview", "spaceSlug", "johns-space"))
|
||||
assert.Equal(t, "#", routeurl.URL("does.not.exist"))
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
package routeurl
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
|
@ -46,7 +47,7 @@ func URL(name string, kv ...string) string {
|
|||
return "#"
|
||||
}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
key, val := kv[i], kv[i+1]
|
||||
key, val := kv[i], url.PathEscape(kv[i+1])
|
||||
path = strings.Replace(path, "{"+key+"...}", val, 1)
|
||||
path = strings.Replace(path, "{"+key+"}", val, 1)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,12 +9,13 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
|||
type SpaceCardInfo struct {
|
||||
ID string
|
||||
Name string
|
||||
Slug string
|
||||
MemberCount int
|
||||
TotalBalance decimal.Decimal
|
||||
}
|
||||
|
||||
templ SpaceCard(info SpaceCardInfo) {
|
||||
<a href={ routeurl.URL("page.app.spaces.space.overview", "spaceID", info.ID) } class="px-2 py-2 block rounded-md hover:bg-sidebar-accent">
|
||||
<a href={ routeurl.URL("page.app.spaces.space.overview", "spaceSlug", info.Slug) } class="px-2 py-2 block rounded-md hover:bg-sidebar-accent">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="w-10 h-10 shrink-0 overflow-hidden rounded-md bg-muted flex items-center justify-center">
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ 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) {
|
||||
templ CreateSpaceSuccess(spaceSlug 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)}) {
|
||||
@button.Button(button.Props{Class: "w-full", Href: routeurl.URL("page.app.spaces.space.overview", "spaceSlug", spaceSlug)}) {
|
||||
Start tracking expenses
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue