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 (
|
const (
|
||||||
UserKey string = "user"
|
UserKey string = "user"
|
||||||
|
SpaceKey string = "space"
|
||||||
|
|
||||||
URLPathKey string = "url_path"
|
URLPathKey string = "url_path"
|
||||||
ConfigKey string = "config"
|
ConfigKey string = "config"
|
||||||
|
|
@ -25,6 +26,15 @@ func WithUser(ctx context.Context, user *model.User) context.Context {
|
||||||
return context.WithValue(ctx, UserKey, user)
|
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 {
|
func URLPath(ctx context.Context) string {
|
||||||
path, _ := ctx.Value(URLPathKey).(string)
|
path, _ := ctx.Value(URLPathKey).(string)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"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/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"
|
||||||
|
|
@ -61,6 +62,7 @@ func (h *spaceHandler) SpacesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
cards = append(cards, blocks.SpaceCardInfo{
|
cards = append(cards, blocks.SpaceCardInfo{
|
||||||
ID: sp.ID,
|
ID: sp.ID,
|
||||||
Name: sp.Name,
|
Name: sp.Name,
|
||||||
|
Slug: slug.Make(sp.Name),
|
||||||
MemberCount: memberCount,
|
MemberCount: memberCount,
|
||||||
TotalBalance: totalBalance,
|
TotalBalance: totalBalance,
|
||||||
})
|
})
|
||||||
|
|
@ -102,18 +104,14 @@ func (h *spaceHandler) HandleCreateSpace(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
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) {
|
func (h *spaceHandler) SpaceOverviewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
space := ctxkeys.Space(r.Context())
|
||||||
|
if space == nil {
|
||||||
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())
|
ui.Render(w, r, pages.NotFound())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.Render(w, r, pages.SpaceOverview(space.Name))
|
ui.Render(w, r, pages.SpaceOverview(space.Name))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,16 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"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"
|
"git.juancwu.dev/juancwu/budgit/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RequireSpaceAccess validates that a user is a member of the space they are trying to access.
|
// RequireSpaceAccess resolves the {spaceName} path parameter against the
|
||||||
// It expects a URL parameter named "spaceID".
|
// 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 {
|
func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) http.HandlerFunc {
|
||||||
return func(next http.Handler) 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) {
|
||||||
|
|
@ -20,26 +25,43 @@ func RequireSpaceAccess(spaceService *service.SpaceService) func(http.Handler) h
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceSlug := r.PathValue("spaceSlug")
|
||||||
if spaceID == "" {
|
if spaceSlug == "" {
|
||||||
slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceID} path parameter")
|
slog.Warn("RequireSpaceAccess middleware used on a route without a {spaceSlug} path parameter")
|
||||||
notfound(w, r)
|
notfound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isMember, err := spaceService.IsMember(user.ID, spaceID)
|
spaces, err := spaceService.GetSpacesForUser(user.ID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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)
|
redirect(w, r, "/forbidden", http.StatusSeeOther)
|
||||||
return
|
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("", spaceH.SpacesPage).Name("page.app.spaces")
|
||||||
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
|
g.Get("/create", spaceH.CreateSpacePage).Name("page.app.spaces.create")
|
||||||
g.Post("/create", spaceH.HandleCreateSpace).Name("action.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)
|
spaceAccessMw := middleware.RequireSpaceAccess(a.SpaceService)
|
||||||
g.Use(spaceAccessMw)
|
g.Use(spaceAccessMw)
|
||||||
g.Get("/overview", spaceH.SpaceOverviewPage).Name("page.app.spaces.space.overview")
|
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, "/privacy", routeurl.URL("page.public.privacy"))
|
||||||
assert.Equal(t, "/app/spaces", routeurl.URL("page.app.spaces"))
|
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, "/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"))
|
assert.Equal(t, "#", routeurl.URL("does.not.exist"))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
package routeurl
|
package routeurl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
@ -46,7 +47,7 @@ func URL(name string, kv ...string) string {
|
||||||
return "#"
|
return "#"
|
||||||
}
|
}
|
||||||
for i := 0; i+1 < len(kv); i += 2 {
|
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)
|
||||||
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 {
|
type SpaceCardInfo struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
|
Slug string
|
||||||
MemberCount int
|
MemberCount int
|
||||||
TotalBalance decimal.Decimal
|
TotalBalance decimal.Decimal
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SpaceCard(info SpaceCardInfo) {
|
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 items-center justify-between">
|
||||||
<div class="flex gap-4 items-center">
|
<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">
|
<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/ui/components/button"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
|
||||||
|
|
||||||
templ CreateSpaceSuccess(spaceID string) {
|
templ CreateSpaceSuccess(spaceSlug string) {
|
||||||
<div>
|
<div>
|
||||||
@card.Card(card.Props{Class: "rounded-sm"}) {
|
@card.Card(card.Props{Class: "rounded-sm"}) {
|
||||||
@card.Content(card.ContentProps{Class: "p-4"}) {
|
@card.Content(card.ContentProps{Class: "p-4"}) {
|
||||||
<p class="text-xl text-success">Space successfully created!</p>
|
<p class="text-xl text-success">Space successfully created!</p>
|
||||||
}
|
}
|
||||||
@card.Footer() {
|
@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
|
Start tracking expenses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue