Yōten: A social tracker for your language learning journey built on the atproto.

refactor: re-architect application directory structure

The project has sufficiently grown out enough to need more structure.
There were certain dependencies that shouldn't have been there such as
some db statements requiring network calls. This has now been fixed.

brookjeynes.dev 7b9f4bcc 45597a83

verified
+946 -877
+3 -3
.dockerignore
··· 1 - **/*.db 2 - **/*.db-* 1 + *.db 2 + *.db-* 3 3 **/*/**/*templ.go 4 4 jwks.json 5 - **/internal/web/state/static/* 5 + static/* 6 6 fly.toml 7 7 run.sh
+1 -1
.gitignore
··· 2 2 *.db-* 3 3 jwks.json 4 4 */**/*templ.go 5 - internal/web/state/static/* 5 + static/* 6 6 run.sh
+6 -6
Dockerfile
··· 22 22 RUN mkdir -p ./internal/web/state/static 23 23 24 24 # Download frontend libraries into the static folder 25 - RUN curl -sLo ./internal/web/state/static/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js 26 - RUN curl -sLo ./internal/web/state/static/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js 27 - RUN curl -sLo ./internal/web/state/static/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js 28 - RUN curl -sLo ./internal/web/state/static/htmx-toaster.min.js https://unpkg.com/htmx-toaster@0.0.20/dist/htmx-toaster.min.js 25 + RUN curl -sLo ./static/htmx.min.js https://cdn.jsdelivr.net/npm/htmx.org@2.0.6/dist/htmx.min.js 26 + RUN curl -sLo ./static/lucide.min.js https://unpkg.com/lucide@0.525.0/dist/umd/lucide.min.js 27 + RUN curl -sLo ./static/alpinejs.min.js https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js 28 + RUN curl -sLo ./static/htmx-toaster.min.js https://unpkg.com/htmx-toaster@0.0.20/dist/htmx-toaster.min.js 29 29 30 - RUN tailwindcss -i ./input.css -o ./internal/web/state/static/style.css --minify 30 + RUN tailwindcss -i ./input.css -o ./static/style.css --minify 31 31 32 - RUN go build -v -o /run-app ./cmd/web/main.go 32 + RUN go build -v -o /run-app ./cmd/server/main.go 33 33 34 34 FROM debian:bookworm 35 35
+2 -3
cmd/gen.go
··· 1 1 package main 2 2 3 3 import ( 4 + "maps" 4 5 "reflect" 5 6 "strings" 6 7 ··· 46 47 47 48 func AllLexTypes() map[string]reflect.Type { 48 49 out := make(map[string]reflect.Type, len(lexTypesMap)) 49 - for k, v := range lexTypesMap { 50 - out[k] = v 51 - } 50 + maps.Copy(out, lexTypesMap) 52 51 return out 53 52 }
+8 -5
cmd/web/main.go cmd/server/main.go
··· 8 8 "log" 9 9 "net/http" 10 10 11 - "yoten.app/internal/web/config" 12 - "yoten.app/internal/web/state" 11 + "yoten.app/internal/server" 12 + "yoten.app/internal/server/config" 13 + "yoten.app/internal/server/handlers" 13 14 ) 14 15 15 16 func main() { ··· 20 21 log.Fatalf("failed to load config: %v", err) 21 22 } 22 23 23 - state, err := state.Make(ctx, c) 24 + serverState, err := server.Make(ctx, c) 24 25 if err != nil { 25 26 log.Fatalf("failed to create state: %v", err) 26 27 } 27 28 29 + handler := handlers.NewHandler(serverState) 30 + 28 31 gob.Register(map[string][]uint8{}) 29 32 30 33 server := &http.Server{ 31 34 Addr: fmt.Sprintf(":%s", c.Core.Port), 32 - Handler: state.Router(), 35 + Handler: handler.Router(), 33 36 } 34 37 35 38 log.Printf("Starting server on :%s\n", c.Core.Port) ··· 37 40 log.Fatalf("failed to start server: %v", err) 38 41 } 39 42 40 - if err := state.Close(); err != nil { 43 + if err := serverState.Close(); err != nil { 41 44 log.Printf("Application state cleanup error: %v", err) 42 45 } 43 46
db/seed_activities.sql migrations/seed_activities.sql
db/seed_xp.sql migrations/seed_xp.sql
+79
internal/clients/bsky/bsky.go
··· 1 + package bsky 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "net/http" 7 + "net/url" 8 + 9 + "yoten.app/internal/server/oauth" 10 + "yoten.app/internal/types" 11 + ) 12 + 13 + func GetBskyProfile(actor string) (types.BskyProfile, error) { 14 + profileURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", actor) 15 + 16 + profileResp, err := http.Get(profileURL) 17 + if err != nil { 18 + return types.BskyProfile{}, fmt.Errorf("failed to fetch bsky profile: %w", err) 19 + } 20 + defer profileResp.Body.Close() 21 + 22 + if profileResp.StatusCode != http.StatusOK { 23 + return types.BskyProfile{}, fmt.Errorf("failed to fetch bsky profile, status: %s", profileResp.Status) 24 + } 25 + 26 + var profile types.BskyProfile 27 + if err := json.NewDecoder(profileResp.Body).Decode(&profile); err != nil { 28 + return types.BskyProfile{}, fmt.Errorf("failed to decode bsky profile JSON: %w", err) 29 + } 30 + 31 + return profile, nil 32 + } 33 + 34 + func GetBskyProfiles(actors []string) (map[string]types.BskyProfile, error) { 35 + if len(actors) == 0 { 36 + return make(map[string]types.BskyProfile), nil 37 + } 38 + apiURL := "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles" 39 + params := url.Values{} 40 + for _, did := range actors { 41 + params.Add("actors", did) 42 + } 43 + resp, err := http.Get(apiURL + "?" + params.Encode()) 44 + if err != nil { 45 + return nil, fmt.Errorf("failed to fetch bsky profiles: %w", err) 46 + } 47 + defer resp.Body.Close() 48 + if resp.StatusCode != http.StatusOK { 49 + return nil, fmt.Errorf("failed to fetch bsky profiles, status: %s", resp.Status) 50 + } 51 + var response struct { 52 + Profiles []types.BskyProfile `json:"profiles"` 53 + } 54 + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 55 + return nil, fmt.Errorf("failed to decode bsky profiles JSON: %w", err) 56 + } 57 + profileMap := make(map[string]types.BskyProfile) 58 + for _, profile := range response.Profiles { 59 + profileMap[profile.Did] = profile 60 + } 61 + return profileMap, nil 62 + } 63 + 64 + func GetUserWithBskyProfile(o *oauth.OAuth, r *http.Request) (*types.User, error) { 65 + oauth := o.GetUser(r) 66 + if oauth == nil { 67 + return nil, fmt.Errorf("failed to get oauth user") 68 + } 69 + 70 + bskyProfile, err := GetBskyProfile(oauth.Did) 71 + if err != nil { 72 + return nil, fmt.Errorf("failed to get bsky profile:, %w", err) 73 + } 74 + 75 + return &types.User{ 76 + OauthUser: *oauth, 77 + BskyProfile: bskyProfile, 78 + }, nil 79 + }
+143
internal/server/app.go
··· 1 + package server 2 + 3 + import ( 4 + "cmp" 5 + "context" 6 + "fmt" 7 + "log/slog" 8 + "net/http" 9 + "slices" 10 + "strings" 11 + 12 + "github.com/posthog/posthog-go" 13 + 14 + "yoten.app/api/yoten" 15 + "yoten.app/internal/atproto" 16 + "yoten.app/internal/clients/bsky" 17 + "yoten.app/internal/consumer" 18 + "yoten.app/internal/db" 19 + "yoten.app/internal/server/config" 20 + "yoten.app/internal/server/oauth" 21 + "yoten.app/internal/server/views" 22 + ) 23 + 24 + type ComputedData struct { 25 + SortedLanguages []db.Language 26 + SortedCategories []db.Category 27 + SortedResourceTypes []db.ResourceType 28 + SortedReactions []db.Reaction 29 + } 30 + 31 + type Server struct { 32 + Db *db.DB 33 + Oauth *oauth.OAuth 34 + Config *config.Config 35 + Posthog posthog.Client 36 + IdResolver *atproto.Resolver 37 + ComputedData ComputedData 38 + } 39 + 40 + func (s *Server) Close() error { 41 + s.Posthog.Close() 42 + 43 + return nil 44 + } 45 + 46 + func Make(ctx context.Context, config *config.Config) (*Server, error) { 47 + d, err := db.Make(config.Core.DbPath) 48 + if err != nil { 49 + return nil, err 50 + } 51 + 52 + oauth := oauth.NewOAuth(d, config) 53 + 54 + idResolver := atproto.DefaultResolver() 55 + 56 + posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 57 + if err != nil { 58 + return nil, fmt.Errorf("failed to create posthog client: %w", err) 59 + } 60 + 61 + wrapper := db.DbWrapper{Execer: d} 62 + jc, err := consumer.NewJetstreamClient( 63 + config.Jetstream.Endpoint, 64 + "yoten", 65 + []string{yoten.ActorProfileNSID, 66 + yoten.FeedSessionNSID, 67 + yoten.FeedResourceNSID, 68 + yoten.FeedReactionNSID, 69 + yoten.ActivityDefNSID, 70 + yoten.GraphFollowNSID, 71 + }, 72 + nil, 73 + slog.Default(), 74 + wrapper, 75 + false, 76 + ) 77 + if err != nil { 78 + return nil, fmt.Errorf("failed to create jetstream client: %w", err) 79 + } 80 + 81 + ingester := consumer.Ingester{ 82 + Db: wrapper, 83 + Config: config} 84 + err = jc.StartJetstream(ctx, ingester.Ingest()) 85 + if err != nil { 86 + return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 87 + } 88 + 89 + sortedLanguages := make([]db.Language, 0, len(db.Languages)) 90 + for _, l := range db.Languages { 91 + sortedLanguages = append(sortedLanguages, l) 92 + } 93 + slices.SortFunc(sortedLanguages, func(a, b db.Language) int { 94 + return strings.Compare(a.Name, b.Name) 95 + }) 96 + 97 + sortedCategories := make([]db.Category, 0, len(db.Categories)) 98 + for _, c := range db.Categories { 99 + sortedCategories = append(sortedCategories, c) 100 + } 101 + slices.SortFunc(sortedCategories, func(a, b db.Category) int { 102 + return strings.Compare(a.Name, b.Name) 103 + }) 104 + 105 + sortedResourceTypes := make([]db.ResourceType, 0, len(db.ResourceTypeMap)) 106 + for _, value := range db.ResourceTypeMap { 107 + sortedResourceTypes = append(sortedResourceTypes, value) 108 + } 109 + slices.SortFunc(sortedResourceTypes, func(a, b db.ResourceType) int { 110 + return strings.Compare(a.String(), b.String()) 111 + }) 112 + 113 + sortedReactions := make([]db.Reaction, 0, len(db.Reactions)) 114 + for _, reaction := range db.Reactions { 115 + sortedReactions = append(sortedReactions, reaction) 116 + } 117 + slices.SortFunc(sortedReactions, func(a, b db.Reaction) int { 118 + return cmp.Compare(a.ID, b.ID) 119 + }) 120 + 121 + state := &Server{ 122 + Db: d, 123 + Oauth: oauth, 124 + Config: config, 125 + Posthog: posthog, 126 + IdResolver: idResolver, 127 + ComputedData: ComputedData{ 128 + SortedLanguages: sortedLanguages, 129 + SortedCategories: sortedCategories, 130 + SortedResourceTypes: sortedResourceTypes, 131 + SortedReactions: sortedReactions, 132 + }, 133 + } 134 + 135 + return state, nil 136 + } 137 + 138 + func (s *Server) HandleIndexPage(w http.ResponseWriter, r *http.Request) { 139 + user, _ := bsky.GetUserWithBskyProfile(s.Oauth, r) 140 + views.IndexPage(views.IndexPageParams{ 141 + User: user, 142 + }).Render(r.Context(), w) 143 + }
+139
internal/server/handlers/router.go
··· 1 + package handlers 2 + 3 + import ( 4 + "net/http" 5 + "strings" 6 + 7 + "github.com/go-chi/chi/v5" 8 + "github.com/gorilla/sessions" 9 + 10 + "yoten.app/internal/server" 11 + "yoten.app/internal/server/middleware" 12 + oauthhandler "yoten.app/internal/server/oauth/handler" 13 + "yoten.app/internal/server/views" 14 + ) 15 + 16 + type Handler struct { 17 + *server.Server 18 + } 19 + 20 + func NewHandler(s *server.Server) *Handler { 21 + return &Handler{s} 22 + } 23 + 24 + func (h *Handler) Router() http.Handler { 25 + router := chi.NewRouter() 26 + middleware := middleware.New( 27 + h.Oauth, 28 + h.Db, 29 + h.IdResolver, 30 + ) 31 + 32 + router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 33 + pat := chi.URLParam(r, "*") 34 + if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 35 + h.UserRouter(&middleware).ServeHTTP(w, r) 36 + } else { 37 + h.StandardRouter(&middleware).ServeHTTP(w, r) 38 + } 39 + }) 40 + 41 + return router 42 + } 43 + 44 + func (h *Handler) StandardRouter(mw *middleware.Middleware) http.Handler { 45 + r := chi.NewRouter() 46 + 47 + r.Handle("/static/*", h.HandleStatic()) 48 + r.Get("/", h.HandleIndexPage) 49 + r.Get("/feed", h.HandleStudySessionFeed) 50 + 51 + r.Mount("/", h.OAuthRouter()) 52 + 53 + r.Route("/friends", func(r chi.Router) { 54 + r.Use(middleware.AuthMiddleware(h.Oauth)) 55 + r.Get("/", h.HandleFriendsPage) 56 + r.Get("/feed", h.HandleFriendsFeed) 57 + }) 58 + 59 + r.Route("/profile", func(r chi.Router) { 60 + r.Use(middleware.AuthMiddleware(h.Oauth)) 61 + r.Get("/edit", h.HandleEditProfilePage) 62 + r.Post("/edit", h.HandleEditProfilePage) 63 + r.Get("/activities", h.HandleActivitiesPage) 64 + r.Get("/resources", h.HandleResourcesPage) 65 + }) 66 + 67 + r.Route("/follow", func(r chi.Router) { 68 + r.Use(middleware.AuthMiddleware(h.Oauth)) 69 + r.Post("/", h.HandleFollow) 70 + r.Delete("/", h.HandleFollow) 71 + }) 72 + 73 + r.Route("/resource", func(r chi.Router) { 74 + r.Use(middleware.AuthMiddleware(h.Oauth)) 75 + r.Get("/new", h.HandleNewResourcePage) 76 + r.Post("/new", h.HandleNewResourcePage) 77 + r.Get("/edit/{rkey}", h.HandleEditResourcePage) 78 + r.Post("/edit/{rkey}", h.HandleEditResourcePage) 79 + r.Delete("/{rkey}", h.HandleDeleteResource) 80 + }) 81 + 82 + r.Route("/activity", func(r chi.Router) { 83 + r.Use(middleware.AuthMiddleware(h.Oauth)) 84 + r.Get("/new", h.HandleNewActivityPage) 85 + r.Post("/new", h.HandleNewActivityPage) 86 + r.Post("/edit/{rkey}", h.HandleEditActivityPage) 87 + r.Get("/edit/{rkey}", h.HandleEditActivityPage) 88 + r.Delete("/{rkey}", h.HandleDeleteActivity) 89 + }) 90 + 91 + r.Route("/stats", func(r chi.Router) { 92 + r.Use(middleware.AuthMiddleware(h.Oauth)) 93 + r.Get("/", h.HandleStatsPage) 94 + r.Get("/time-per-graphs", h.HandleTimePerGraphs) 95 + }) 96 + 97 + r.Route("/session", func(r chi.Router) { 98 + r.Use(middleware.AuthMiddleware(h.Oauth)) 99 + r.Post("/edit/{rkey}", h.HandleEditStudySessionPage) 100 + r.Get("/edit/{rkey}", h.HandleEditStudySessionPage) 101 + r.Get("/new", h.HandleNewStudySessionPage) 102 + r.Post("/new", h.HandleNewStudySessionPage) 103 + r.Delete("/{rkey}", h.HandleDeleteStudySession) 104 + 105 + r.Route("/reaction", func(r chi.Router) { 106 + r.Post("/", h.HandleReaction) 107 + r.Delete("/", h.HandleReaction) 108 + }) 109 + }) 110 + 111 + return r 112 + } 113 + 114 + func (h *Handler) UserRouter(mw *middleware.Middleware) http.Handler { 115 + r := chi.NewRouter() 116 + 117 + r.Use(middleware.StripLeadingAt) 118 + 119 + r.Group(func(r chi.Router) { 120 + r.Use(mw.ResolveIdent()) 121 + r.Route("/{user}", func(r chi.Router) { 122 + r.Get("/", h.HandleProfilePage) 123 + r.Get("/feed", h.HandleProfileFeed) 124 + }) 125 + }) 126 + 127 + r.NotFound(func(w http.ResponseWriter, r *http.Request) { 128 + w.WriteHeader(http.StatusNotFound) 129 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 130 + }) 131 + 132 + return r 133 + } 134 + 135 + func (h *Handler) OAuthRouter() http.Handler { 136 + store := sessions.NewCookieStore([]byte(h.Config.Core.CookieSecret)) 137 + oauth := oauthhandler.New(h.Config, h.Db, store, h.Oauth, h.Posthog) 138 + return oauth.Router() 139 + }
+37
internal/server/handlers/static.go
··· 1 + package handlers 2 + 3 + import ( 4 + "io/fs" 5 + "log" 6 + "net/http" 7 + "strings" 8 + 9 + "yoten.app/static" 10 + ) 11 + 12 + func (h *Handler) HandleStatic() http.Handler { 13 + if h.Config.Core.Dev { 14 + return http.StripPrefix("/static/", http.FileServer(http.Dir("static"))) 15 + } 16 + 17 + sub, err := fs.Sub(static.StaticFiles, "static") 18 + if err != nil { 19 + log.Fatal("failed to find static folder:", err) 20 + } 21 + return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 22 + } 23 + 24 + func Cache(h http.Handler) http.Handler { 25 + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 + path := strings.Split(r.URL.Path, "?")[0] 27 + 28 + if strings.HasSuffix(path, ".js") { 29 + // Cache minified js for a year 30 + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 31 + } else { 32 + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 33 + } 34 + 35 + h.ServeHTTP(w, r) 36 + }) 37 + }
+5
internal/server/views/layouts/layouts.go
··· 1 + package layouts 2 + 3 + type BaseParams struct { 4 + Title string 5 + }
+18
internal/types/types.go
··· 1 + package types 2 + 3 + type OauthUser struct { 4 + Handle string 5 + Did string 6 + Pds string 7 + } 8 + 9 + type User struct { 10 + OauthUser 11 + BskyProfile BskyProfile 12 + } 13 + 14 + type BskyProfile struct { 15 + Did string `json:"did"` 16 + Avatar string `json:"avatar"` 17 + Handle string `json:"handle"` 18 + }
-65
internal/web/bsky/bsky.go
··· 1 - package bsky 2 - 3 - import ( 4 - "encoding/json" 5 - "fmt" 6 - "net/http" 7 - "net/url" 8 - ) 9 - 10 - type BskyProfile struct { 11 - Did string `json:"did"` 12 - Avatar string `json:"avatar"` 13 - Handle string `json:"handle"` 14 - } 15 - 16 - func GetBskyProfile(actor string) (BskyProfile, error) { 17 - profileURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", actor) 18 - 19 - profileResp, err := http.Get(profileURL) 20 - if err != nil { 21 - return BskyProfile{}, fmt.Errorf("failed to fetch bsky profile: %w", err) 22 - } 23 - defer profileResp.Body.Close() 24 - 25 - if profileResp.StatusCode != http.StatusOK { 26 - return BskyProfile{}, fmt.Errorf("failed to fetch bsky profile, status: %s", profileResp.Status) 27 - } 28 - 29 - var profile BskyProfile 30 - if err := json.NewDecoder(profileResp.Body).Decode(&profile); err != nil { 31 - return BskyProfile{}, fmt.Errorf("failed to decode bsky profile JSON: %w", err) 32 - } 33 - 34 - return profile, nil 35 - } 36 - 37 - func GetBskyProfiles(actors []string) (map[string]BskyProfile, error) { 38 - if len(actors) == 0 { 39 - return make(map[string]BskyProfile), nil 40 - } 41 - apiURL := "https://public.api.bsky.app/xrpc/app.bsky.actor.getProfiles" 42 - params := url.Values{} 43 - for _, did := range actors { 44 - params.Add("actors", did) 45 - } 46 - resp, err := http.Get(apiURL + "?" + params.Encode()) 47 - if err != nil { 48 - return nil, fmt.Errorf("failed to fetch bsky profiles: %w", err) 49 - } 50 - defer resp.Body.Close() 51 - if resp.StatusCode != http.StatusOK { 52 - return nil, fmt.Errorf("failed to fetch bsky profiles, status: %s", resp.Status) 53 - } 54 - var response struct { 55 - Profiles []BskyProfile `json:"profiles"` 56 - } 57 - if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { 58 - return nil, fmt.Errorf("failed to decode bsky profiles JSON: %w", err) 59 - } 60 - profileMap := make(map[string]BskyProfile) 61 - for _, profile := range response.Profiles { 62 - profileMap[profile.Did] = profile 63 - } 64 - return profileMap, nil 65 - }
internal/web/config/config.go internal/server/config/config.go
internal/web/db/activity.go internal/db/activity.go
internal/web/db/category.go internal/db/category.go
internal/web/db/db.go internal/db/db.go
+3 -13
internal/web/db/follow.go internal/db/follow.go
··· 4 4 "fmt" 5 5 "strings" 6 6 "time" 7 - 8 - "yoten.app/internal/web/bsky" 9 7 ) 10 8 11 9 type FollowStatus int ··· 160 158 return nil, err 161 159 } 162 160 163 - return getProfileItemsForDids(e, did, followerDids) 161 + return GetProfileItemsForDids(e, did, followerDids) 164 162 } 165 163 166 164 func GetFollowing(e Execer, did string, limit, offset int) ([]ProfileItem, error) { ··· 188 186 return nil, err 189 187 } 190 188 191 - return getProfileItemsForDids(e, did, followingDids) 189 + return GetProfileItemsForDids(e, did, followingDids) 192 190 } 193 191 194 - func getProfileItemsForDids(e Execer, did string, dids []string) ([]ProfileItem, error) { 192 + func GetProfileItemsForDids(e Execer, did string, dids []string) ([]ProfileItem, error) { 195 193 if len(dids) == 0 { 196 194 return []ProfileItem{}, nil 197 195 } 198 196 199 - bskyProfiles, err := bsky.GetBskyProfiles(dids) 200 - if err != nil { 201 - return nil, fmt.Errorf("failed to get bsky profiles: %w", err) 202 - } 203 - 204 197 args := make([]any, len(dids)) 205 198 for i, did := range dids { 206 199 args[i] = did ··· 228 221 return nil, fmt.Errorf("failed to scan local profile data: %w", err) 229 222 } 230 223 item.Did = subject_did 231 - if bp, ok := bskyProfiles[subject_did]; ok { 232 - item.BskyProfile = bp 233 - } 234 224 235 225 followStatus := GetFollowStatus(e, did, subject_did) 236 226 item.FollowStatus = followStatus
internal/web/db/heatmap.go internal/db/heatmap.go
internal/web/db/jetstream.go internal/db/cursor.go
internal/web/db/language.go internal/db/language.go
internal/web/db/oauth.go internal/db/oauth.go
+11 -2
internal/web/db/profile.go internal/db/profile.go
··· 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 13 14 14 "yoten.app/api/yoten" 15 - "yoten.app/internal/web/bsky" 15 + "yoten.app/internal/types" 16 16 ) 17 17 18 18 var ( ··· 50 50 Level int 51 51 Languages []Language 52 52 StudySessionCount int 53 - BskyProfile bsky.BskyProfile 53 + FollowStatus FollowStatus 54 + } 55 + 56 + type ProfileItemWithBskyProfile struct { 57 + Did string 58 + DisplayName string 59 + Level int 60 + Languages []Language 61 + StudySessionCount int 54 62 FollowStatus FollowStatus 63 + BskyProfile types.BskyProfile 55 64 } 56 65 57 66 func (p *Profile) ProfileAt() syntax.ATURI {
internal/web/db/reaction.go internal/db/reaction.go
internal/web/db/resource.go internal/db/resource.go
internal/web/db/stats.go internal/db/stats.go
+2 -9
internal/web/db/study-session.go internal/db/study-session.go
··· 11 11 "github.com/bluesky-social/indigo/atproto/syntax" 12 12 13 13 "yoten.app/api/yoten" 14 - "yoten.app/internal/web/bsky" 14 + "yoten.app/internal/types" 15 15 ) 16 16 17 17 var ( ··· 51 51 StudySession 52 52 ProfileDisplayName string 53 53 ProfileLevel int 54 - BskyProfile bsky.BskyProfile 54 + BskyProfile types.BskyProfile 55 55 } 56 56 57 57 func GetStudySessionFeed(e Execer, limit, offset int) ([]*StudySessionFeedItem, error) { ··· 379 379 380 380 item.StudySession = session 381 381 item.Activity.ID = activityId 382 - 383 - // TODO: Move out of db layer 384 - user, err := bsky.GetBskyProfile(item.Did) 385 - if err != nil { 386 - log.Println("failed to get user avatar:", err) 387 - } 388 - item.BskyProfile = user 389 382 390 383 return item, activityId, nil 391 384 }
internal/web/db/utils.go internal/db/utils.go
internal/web/db/xp.go internal/db/xp.go
internal/web/google/safe-browsing.go internal/clients/google/safe-browsing.go
+1 -1
internal/web/html-utils.go internal/server/views/partials/utils.go
··· 1 - package web 1 + package partials 2 2 3 3 import "strings" 4 4
internal/web/htmx/htmx.go internal/server/htmx/htmx.go
+4 -4
internal/web/ingester/ingester.go internal/consumer/ingester.go
··· 1 - package ingester 1 + package consumer 2 2 3 3 import ( 4 4 "context" ··· 12 12 "github.com/bluesky-social/jetstream/pkg/models" 13 13 14 14 "yoten.app/api/yoten" 15 - "yoten.app/internal/web/config" 16 - "yoten.app/internal/web/db" 17 - "yoten.app/internal/web/google" 15 + "yoten.app/internal/clients/google" 16 + "yoten.app/internal/db" 17 + "yoten.app/internal/server/config" 18 18 ) 19 19 20 20 type Ingester struct {
+2 -2
internal/web/jetstream/jetstream.go internal/consumer/jetstream.go
··· 1 - package jetstream 1 + package consumer 2 2 3 3 import ( 4 4 "context" ··· 14 14 "github.com/bluesky-social/jetstream/pkg/client/schedulers/sequential" 15 15 "github.com/bluesky-social/jetstream/pkg/models" 16 16 17 - "yoten.app/internal/web/log" 17 + "yoten.app/internal/server/log" 18 18 ) 19 19 20 20 type DB interface {
internal/web/log/log.go internal/server/log/log.go
+7 -7
internal/web/middleware/middleware.go internal/server/middleware/middleware.go
··· 9 9 10 10 "github.com/go-chi/chi/v5" 11 11 12 - "yoten.app/internal/web/db" 13 - "yoten.app/internal/web/oauth" 14 - "yoten.app/internal/web/pages" 15 - "yoten.app/internal/web/resolver" 12 + "yoten.app/internal/atproto" 13 + "yoten.app/internal/db" 14 + "yoten.app/internal/server/oauth" 15 + "yoten.app/internal/server/views" 16 16 ) 17 17 18 18 type Middleware struct { 19 19 oauth *oauth.OAuth 20 20 db *db.DB 21 - idResolver *resolver.Resolver 21 + idResolver *atproto.Resolver 22 22 } 23 23 24 - func New(oauth *oauth.OAuth, db *db.DB, idResolver *resolver.Resolver) Middleware { 24 + func New(oauth *oauth.OAuth, db *db.DB, idResolver *atproto.Resolver) Middleware { 25 25 return Middleware{ 26 26 oauth: oauth, 27 27 db: db, ··· 76 76 if err != nil { 77 77 log.Println("failed to resolve did/handle:", err) 78 78 w.WriteHeader(http.StatusNotFound) 79 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 79 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 80 80 return 81 81 } 82 82
internal/web/oauth/client/client.go internal/server/oauth/client/client.go
internal/web/oauth/consts.go internal/server/oauth/consts.go
+15 -15
internal/web/oauth/handler/handler.go internal/server/oauth/handler/handler.go
··· 17 17 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 18 18 19 19 "yoten.app/api/yoten" 20 - "yoten.app/internal/web" 21 - "yoten.app/internal/web/bsky" 22 - "yoten.app/internal/web/config" 23 - "yoten.app/internal/web/db" 24 - "yoten.app/internal/web/htmx" 25 - "yoten.app/internal/web/middleware" 26 - "yoten.app/internal/web/oauth" 27 - "yoten.app/internal/web/oauth/client" 28 - "yoten.app/internal/web/pages" 29 - ph "yoten.app/internal/web/posthog" 30 - "yoten.app/internal/web/resolver" 20 + "yoten.app/internal/atproto" 21 + "yoten.app/internal/clients/bsky" 22 + ph "yoten.app/internal/clients/posthog" 23 + "yoten.app/internal/db" 24 + "yoten.app/internal/server/config" 25 + "yoten.app/internal/server/htmx" 26 + "yoten.app/internal/server/middleware" 27 + "yoten.app/internal/server/oauth" 28 + "yoten.app/internal/server/oauth/client" 29 + "yoten.app/internal/server/views" 30 + "yoten.app/internal/types" 31 31 ) 32 32 33 33 const ( ··· 76 76 func (o *OAuthHandler) HandleLoginPage(w http.ResponseWriter, r *http.Request) { 77 77 switch r.Method { 78 78 case http.MethodGet: 79 - var user *web.User 79 + var user *types.User 80 80 oauth := o.oauth.GetUser(r) 81 81 if oauth != nil { 82 82 bskyProfile, err := bsky.GetBskyProfile(oauth.Did) 83 83 if err != nil { 84 84 log.Println("failed to get bsky profile:", err) 85 85 } 86 - user = &web.User{ 86 + user = &types.User{ 87 87 OauthUser: *oauth, 88 88 BskyProfile: bskyProfile, 89 89 } 90 90 } 91 - pages.LoginPage(pages.LoginPageParams{ 91 + views.LoginPage(views.LoginPageParams{ 92 92 User: user, 93 93 }).Render(r.Context(), w) 94 94 case http.MethodPost: ··· 109 109 110 110 handle = strings.TrimPrefix(handle, "@") 111 111 112 - idResolver := resolver.DefaultResolver() 112 + idResolver := atproto.DefaultResolver() 113 113 resolved, err := idResolver.ResolveIdent(r.Context(), handle) 114 114 if err != nil { 115 115 log.Println("failed to resolve handle:", err)
+7 -7
internal/web/oauth/oauth.go internal/server/oauth/oauth.go
··· 11 11 oauth "tangled.sh/icyphox.sh/atproto-oauth" 12 12 "tangled.sh/icyphox.sh/atproto-oauth/helpers" 13 13 14 - "yoten.app/internal/web" 15 - "yoten.app/internal/web/config" 16 - "yoten.app/internal/web/db" 17 - "yoten.app/internal/web/oauth/client" 18 - xrpc "yoten.app/internal/web/xrpcclient" 14 + xrpc "yoten.app/internal/atproto" 15 + "yoten.app/internal/db" 16 + "yoten.app/internal/server/config" 17 + "yoten.app/internal/server/oauth/client" 18 + "yoten.app/internal/types" 19 19 ) 20 20 21 21 type OAuth struct { ··· 159 159 return session, auth, nil 160 160 } 161 161 162 - func (a *OAuth) GetUser(r *http.Request) *web.OauthUser { 162 + func (a *OAuth) GetUser(r *http.Request) *types.OauthUser { 163 163 clientSession, err := a.Store.Get(r, SessionName) 164 164 if err != nil || clientSession.IsNew { 165 165 return nil 166 166 } 167 167 168 - return &web.OauthUser{ 168 + return &types.OauthUser{ 169 169 Handle: clientSession.Values[SessionHandle].(string), 170 170 Did: clientSession.Values[SessionDid].(string), 171 171 Pds: clientSession.Values[SessionPds].(string),
+3 -3
internal/web/pages/404.templ internal/server/views/404.templ
··· 1 - package pages 1 + package views 2 2 3 - import "yoten.app/internal/web/pages/templates" 3 + import "yoten.app/internal/server/views/layouts" 4 4 5 5 templ NotFoundPage(params NotFoundPageParams) { 6 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "404"}) { 6 + @layouts.Base(layouts.BaseParams{Title: "404"}) { 7 7 <div class="container mx-auto px-4 py-16 max-w-2xl"> 8 8 <div class="flex flex-col text-center gap-8"> 9 9 <div class="flex flex-col gap-4">
+4 -4
internal/web/pages/activities.templ internal/server/views/activities.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/pages/partials" 5 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 6 ) 7 7 8 8 templ ActivitiesPage(params ActivitiesPageParams) { 9 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "activities"}) { 9 + @layouts.Base(layouts.BaseParams{Title: "activities"}) { 10 10 @partials.Header(partials.HeaderProps{User: params.User}) 11 11 <div class="container mx-auto px-4 py-8 max-w-2xl"> 12 12 <div class="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
+5 -5
internal/web/pages/edit-activity.templ internal/server/views/edit-activity.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 "slices" 6 6 7 - "yoten.app/internal/web/db" 8 - "yoten.app/internal/web/pages/partials" 9 - "yoten.app/internal/web/pages/templates" 7 + "yoten.app/internal/db" 8 + "yoten.app/internal/server/views/layouts" 9 + "yoten.app/internal/server/views/partials" 10 10 ) 11 11 12 12 templ EditActivityPage(params EditActivityPageParams) { 13 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "edit activity"}) { 13 + @layouts.Base(layouts.BaseParams{Title: "edit activity"}) { 14 14 @partials.Header(partials.HeaderProps{User: params.User}) 15 15 <div class="container mx-auto px-4 py-8 max-w-2xl"> 16 16 <form
+5 -5
internal/web/pages/edit-profile.templ internal/server/views/edit-profile.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/web/db" 7 - "yoten.app/internal/web/pages/partials" 8 - "yoten.app/internal/web/pages/templates" 6 + "yoten.app/internal/db" 7 + "yoten.app/internal/server/views/layouts" 8 + "yoten.app/internal/server/views/partials" 9 9 ) 10 10 11 11 templ EditProfilePage(params EditProfilePageParams) { 12 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "edit profile"}) { 12 + @layouts.Base(layouts.BaseParams{Title: "edit profile"}) { 13 13 @partials.Header(partials.HeaderProps{User: params.User}) 14 14 <div class="container mx-auto px-4 py-8 max-w-2xl"> 15 15 <form
+5 -5
internal/web/pages/edit-resource.templ internal/server/views/edit-resource.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/web/db" 7 - "yoten.app/internal/web/pages/partials" 8 - "yoten.app/internal/web/pages/templates" 6 + "yoten.app/internal/db" 7 + "yoten.app/internal/server/views/layouts" 8 + "yoten.app/internal/server/views/partials" 9 9 ) 10 10 11 11 templ EditResourcePage(params EditResourcePageParams) { 12 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "edit resource"}) { 12 + @layouts.Base(layouts.BaseParams{Title: "edit resource"}) { 13 13 @partials.Header(partials.HeaderProps{User: params.User}) 14 14 <div class="container mx-auto px-4 py-8 max-w-2xl"> 15 15 <form
+5 -5
internal/web/pages/edit-study-session.templ internal/server/views/edit-study-session.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 "time" 6 6 7 - "yoten.app/internal/web/db" 8 - "yoten.app/internal/web/pages/partials" 9 - "yoten.app/internal/web/pages/templates" 7 + "yoten.app/internal/db" 8 + "yoten.app/internal/server/views/layouts" 9 + "yoten.app/internal/server/views/partials" 10 10 ) 11 11 12 12 templ EditStudySessionPage(params EditStudySessionPageParams) { ··· 18 18 resourceTitle = params.StudySession.Resource.Title 19 19 } 20 20 }} 21 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "edit study session"}) { 21 + @layouts.Base(layouts.BaseParams{Title: "edit study session"}) { 22 22 @partials.Header(partials.HeaderProps{User: params.User}) 23 23 <div class="container mx-auto px-4 py-8 max-w-2xl"> 24 24 <form
+4 -4
internal/web/pages/friends.templ internal/server/views/friends.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/pages/partials" 5 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 6 ) 7 7 8 8 templ FriendsPage(params FriendsPageParams) { 9 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "friends"}) { 9 + @layouts.Base(layouts.BaseParams{Title: "friends"}) { 10 10 @partials.Header(partials.HeaderProps{User: params.User}) 11 11 <div class="container mx-auto max-w-2xl px-4 py-8"> 12 12 <div class="flex items-center justify-between mb-8">
+4 -4
internal/web/pages/index.templ internal/server/views/index.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/pages/partials" 5 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 6 ) 7 7 8 8 templ IndexPage(params IndexPageParams) { 9 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "home"}) { 9 + @layouts.Base(layouts.BaseParams{Title: "home"}) { 10 10 @partials.Header(partials.HeaderProps{User: params.User}) 11 11 <div class="container mx-auto max-w-2xl px-4 py-8"> 12 12 <div class="flex flex-col sm:flex-row sm:items-center justify-between mt-4 mb-8 gap-4">
+3 -3
internal/web/pages/login.templ internal/server/views/login.templ
··· 1 - package pages 1 + package views 2 2 3 - import "yoten.app/internal/web/pages/templates" 3 + import "yoten.app/internal/server/views/layouts" 4 4 5 5 templ LoginPage(params LoginPageParams) { 6 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "login"}) { 6 + @layouts.Base(layouts.BaseParams{Title: "login"}) { 7 7 <div class="container mx-auto px-4 py-16 max-w-md"> 8 8 <div class="text-center mb-8"> 9 9 <h1 class="text-3xl font-bold mb-2">Welcome to Yōten</h1>
+4 -4
internal/web/pages/new-activity.templ internal/server/views/new-activity.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/web/pages/partials" 7 - "yoten.app/internal/web/pages/templates" 6 + "yoten.app/internal/server/views/layouts" 7 + "yoten.app/internal/server/views/partials" 8 8 ) 9 9 10 10 templ NewActivityPage(params NewActivityPageParams) { 11 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "new activity"}) { 11 + @layouts.Base(layouts.BaseParams{Title: "new activity"}) { 12 12 @partials.Header(partials.HeaderProps{User: params.User}) 13 13 <div class="container mx-auto px-4 py-8 max-w-2xl"> 14 14 <form class="card" hx-post="/activity/new" hx-swap="none" hx-disabled-elt="#save-button,#cancel-button">
+5 -5
internal/web/pages/new-resource.templ internal/server/views/new-resource.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/db" 5 - "yoten.app/internal/web/pages/partials" 6 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/db" 5 + "yoten.app/internal/server/views/layouts" 6 + "yoten.app/internal/server/views/partials" 7 7 ) 8 8 9 9 templ NewResourcePage(params NewResourcePageParams) { 10 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "new resource"}) { 10 + @layouts.Base(layouts.BaseParams{Title: "new resource"}) { 11 11 @partials.Header(partials.HeaderProps{User: params.User}) 12 12 <div class="container mx-auto px-4 py-8 max-w-2xl"> 13 13 <form class="card" hx-post="/resource/new" hx-swap="none" hx-disabled-elt="#save-button,#cancel-button">
+4 -4
internal/web/pages/new-study-session.templ internal/server/views/new-study-session.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "time" 5 5 6 - "yoten.app/internal/web/pages/partials" 7 - "yoten.app/internal/web/pages/templates" 6 + "yoten.app/internal/server/views/layouts" 7 + "yoten.app/internal/server/views/partials" 8 8 ) 9 9 10 10 templ NewStudySessionPage(params NewStudySessionPageParams) { ··· 20 20 initialLangCode = string(params.Profile.Languages[0].Code) 21 21 } 22 22 }} 23 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "new study session"}) { 23 + @layouts.Base(layouts.BaseParams{Title: "new study session"}) { 24 24 @partials.Header(partials.HeaderProps{User: params.User}) 25 25 <div class="container mx-auto px-4 py-8 max-w-2xl"> 26 26 <form
+18 -19
internal/web/pages/pages.go internal/server/views/views.go
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "time" 5 5 6 - "yoten.app/internal/web" 7 - "yoten.app/internal/web/bsky" 8 - "yoten.app/internal/web/db" 6 + "yoten.app/internal/db" 7 + "yoten.app/internal/types" 9 8 ) 10 9 11 10 type JsonText struct { ··· 14 13 15 14 type IndexPageParams struct { 16 15 // The current logged in user. 17 - User *web.User 16 + User *types.User 18 17 } 19 18 20 19 type LoginPageParams struct { 21 20 // The current logged in user. 22 - User *web.User 21 + User *types.User 23 22 } 24 23 25 24 type ProfilePageParams struct { 26 25 // The current logged in user. 27 - User *web.User 28 - BskyProfile bsky.BskyProfile 26 + User *types.User 27 + BskyProfile types.BskyProfile 29 28 Profile db.Profile 30 29 FollowStatus db.FollowStatus 31 30 Followers int ··· 37 36 38 37 type EditProfilePageParams struct { 39 38 // The current logged in user. 40 - User *web.User 39 + User *types.User 41 40 Profile db.Profile 42 41 AllLanguages []db.Language 43 42 InitialSelectedLanguages []string ··· 47 46 48 47 type NewStudySessionPageParams struct { 49 48 // The current logged in user. 50 - User *web.User 49 + User *types.User 51 50 Profile db.Profile 52 51 Activities []db.Activity 53 52 Resources []db.Resource ··· 56 55 57 56 type EditStudySessionPageParams struct { 58 57 // The current logged in user. 59 - User *web.User 58 + User *types.User 60 59 StudySession *db.StudySession 61 60 Activities []db.Activity 62 61 Resources []db.Resource ··· 65 64 66 65 type ResourcesPageParams struct { 67 66 // The current logged in user. 68 - User *web.User 67 + User *types.User 69 68 Resources []db.Resource 70 69 } 71 70 72 71 type ActivitiesPageParams struct { 73 72 // The current logged in user. 74 - User *web.User 73 + User *types.User 75 74 Activities []db.Activity 76 75 } 77 76 78 77 type NewActivityPageParams struct { 79 78 // The current logged in user. 80 - User *web.User 79 + User *types.User 81 80 SortedCategories []db.Category 82 81 } 83 82 84 83 type NewResourcePageParams struct { 85 84 // The current logged in user. 86 - User *web.User 85 + User *types.User 87 86 SortedResourceTypes []db.ResourceType 88 87 } 89 88 90 89 type EditResourcePageParams struct { 91 90 // The current logged in user. 92 - User *web.User 91 + User *types.User 93 92 Resource db.Resource 94 93 SortedResourceTypes []db.ResourceType 95 94 } 96 95 97 96 type EditActivityPageParams struct { 98 97 // The current logged in user. 99 - User *web.User 98 + User *types.User 100 99 Activity db.Activity 101 100 SortedCategories []db.Category 102 101 } 103 102 104 103 type FriendsPageParams struct { 105 104 // The current logged in user. 106 - User *web.User 105 + User *types.User 107 106 Followers int 108 107 Following int 109 108 } 110 109 111 110 type StatsPageParams struct { 112 111 // The current logged in user. 113 - User *web.User 112 + User *types.User 114 113 TotalStudyTime time.Duration 115 114 TotalStudySessions int64 116 115 TotalActiveDays int
+3 -8
internal/web/pages/partials/activity.templ internal/server/views/partials/activity.templ
··· 1 1 package partials 2 2 3 - import ( 4 - "fmt" 5 - "yoten.app/internal/web" 6 - ) 3 + import "fmt" 7 4 8 5 templ Activity(params ActivityProps) { 9 - {{ elementId := web.SanitiseHtmlId(fmt.Sprintf("activity-%s-%s", params.Activity.Did, params.Activity.Rkey)) }} 6 + {{ elementId := SanitiseHtmlId(fmt.Sprintf("activity-%s-%s", params.Activity.Did, params.Activity.Rkey)) }} 10 7 <div id={ elementId } class="card"> 11 8 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> 12 9 <div> ··· 19 16 <i class="w-4 h-4 flex-shrink-0" data-lucide="ellipsis"></i> 20 17 </div> 21 18 </summary> 22 - <div 23 - class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark" 24 - > 19 + <div class="absolute flex flex-col right-0 mt-2 p-1 gap-1 rounded w-32 bg-bg-light border border-bg-dark"> 25 20 <button id="edit-button" type="button" class="w-full"> 26 21 <a 27 22 href={ templ.URL(fmt.Sprintf("/activity/edit/%s", params.Activity.Rkey)) }
internal/web/pages/partials/donut-chart.templ internal/server/views/partials/donut-chart.templ
+1 -1
internal/web/pages/partials/follow-button.templ internal/server/views/partials/follow-button.templ
··· 3 3 import ( 4 4 "fmt" 5 5 6 - "yoten.app/internal/web/db" 6 + "yoten.app/internal/db" 7 7 ) 8 8 9 9 templ FollowButton(params FollowButtonProps) {
internal/web/pages/partials/friends-feed.templ internal/server/views/partials/friends-feed.templ
internal/web/pages/partials/header.templ internal/server/views/partials/header.templ
internal/web/pages/partials/heatmap.templ internal/server/views/partials/heatmap.templ
internal/web/pages/partials/horizonal-bar-chart.templ internal/server/views/partials/horizonal-bar-chart.templ
+12 -13
internal/web/pages/partials/partials.go internal/server/views/partials/partials.go
··· 8 8 9 9 "github.com/a-h/templ" 10 10 11 - "yoten.app/internal/web" 12 - "yoten.app/internal/web/bsky" 13 - "yoten.app/internal/web/db" 11 + "yoten.app/internal/db" 12 + "yoten.app/internal/types" 14 13 ) 15 14 16 15 type FeedMode int ··· 44 43 45 44 type FriendsFeedProps struct { 46 45 // The current logged in user 47 - User *web.User 48 - Feed []db.ProfileItem 46 + User *types.User 47 + Feed []db.ProfileItemWithBskyProfile 49 48 FeedMode FeedMode 50 49 NextPage int 51 50 } 52 51 53 52 type ProfileFeedProps struct { 54 53 // The current logged in user 55 - User *web.User 54 + User *types.User 56 55 Feed []*db.StudySessionFeedItem 57 56 NextPage int 58 - BskyProfile bsky.BskyProfile 57 + BskyProfile types.BskyProfile 59 58 Profile db.Profile 60 59 // Is this profile their own. 61 60 IsSelf bool ··· 63 62 64 63 type StudySessionFeedProps struct { 65 64 // The current logged in user 66 - User *web.User 65 + User *types.User 67 66 Feed []*db.StudySessionFeedItem 68 67 NextPage int 69 68 IsFriendsFeed bool ··· 71 70 72 71 type StudySessionProps struct { 73 72 // The current logged in user 74 - User *web.User 73 + User *types.User 75 74 // Does the current logged in user own this study session. 76 75 DoesOwn bool 77 76 StudySession db.StudySessionFeedItem ··· 79 78 80 79 type HeaderProps struct { 81 80 // The current logged in user. 82 - User *web.User 81 + User *types.User 83 82 } 84 83 85 84 type ActivityProps struct { ··· 101 100 102 101 type NewReactionsProps struct { 103 102 // The current logged in user. 104 - User *web.User 103 + User *types.User 105 104 SessionDid string 106 105 SessionRkey string 107 106 ReactionEvents []db.ReactionEvent ··· 133 132 } 134 133 135 134 link := fmt.Sprintf("/session/reaction?did=%s&rkey=%s", params.SessionDid, params.SessionRkey) 136 - elementId := web.SanitiseHtmlId(fmt.Sprintf("reactions-%s-%s", params.SessionDid, params.SessionRkey)) 135 + elementId := SanitiseHtmlId(fmt.Sprintf("reactions-%s-%s", params.SessionDid, params.SessionRkey)) 137 136 138 137 // Can't use calculated state here as we don't have access to global state. 139 138 sortedReactions := make([]db.Reaction, 0, len(db.Reactions)) ··· 154 153 } 155 154 156 155 type ProfileProps struct { 157 - Profile db.ProfileItem 156 + Profile db.ProfileItemWithBskyProfile 158 157 isFollowing bool 159 158 } 160 159
internal/web/pages/partials/pie-chart.templ internal/server/views/partials/pie-chart.templ
internal/web/pages/partials/profile-feed.templ internal/server/views/partials/profile-feed.templ
internal/web/pages/partials/profile.templ internal/server/views/partials/profile.templ
internal/web/pages/partials/reactions.templ internal/server/views/partials/reactions.templ
+2 -3
internal/web/pages/partials/resource.templ internal/server/views/partials/resource.templ
··· 5 5 "net/url" 6 6 "strings" 7 7 8 - "yoten.app/internal/web" 9 - "yoten.app/internal/web/db" 8 + "yoten.app/internal/db" 10 9 ) 11 10 12 11 templ link(link *string, resourceType db.ResourceType) { ··· 35 34 } 36 35 37 36 templ Resource(params ResourceProps) { 38 - {{ elementId := web.SanitiseHtmlId(fmt.Sprintf("resource-%s-%s", params.Resource.Did, params.Resource.Rkey)) }} 37 + {{ elementId := SanitiseHtmlId(fmt.Sprintf("resource-%s-%s", params.Resource.Did, params.Resource.Rkey)) }} 39 38 <div id={ elementId } class="card"> 40 39 <div class="flex items-start justify-between"> 41 40 <div class="flex items-start gap-4 flex-1">
internal/web/pages/partials/study-session-feed.templ internal/server/views/partials/study-session-feed.templ
+2 -3
internal/web/pages/partials/study-session.templ internal/server/views/partials/study-session.templ
··· 5 5 "net/url" 6 6 "strings" 7 7 8 - "yoten.app/internal/web" 9 - "yoten.app/internal/web/db" 8 + "yoten.app/internal/db" 10 9 ) 11 10 12 11 func getResourceIcon(resourceType db.ResourceType) string { ··· 33 32 } 34 33 35 34 templ StudySession(params StudySessionProps) { 36 - {{ elementId := web.SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }} 35 + {{ elementId := SanitiseHtmlId(fmt.Sprintf("study-session-%s-%s", params.StudySession.Did, params.StudySession.Rkey)) }} 37 36 <div id={ elementId } class="card relative" x-data="{ open: false }" :class="{ 'z-20': open }"> 38 37 <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> 39 38 <div class="flex items-center gap-3">
internal/web/pages/partials/time-per-charts.templ internal/server/views/partials/time-per-charts.templ
+5 -5
internal/web/pages/profile.templ internal/server/views/profile.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 4 "fmt" 5 5 "math" 6 6 7 - "yoten.app/internal/web/db" 8 - "yoten.app/internal/web/pages/partials" 9 - "yoten.app/internal/web/pages/templates" 7 + "yoten.app/internal/db" 8 + "yoten.app/internal/server/views/layouts" 9 + "yoten.app/internal/server/views/partials" 10 10 ) 11 11 12 12 templ ProfilePage(params ProfilePageParams) { 13 13 {{ isSelf := params.User != nil && params.User.Did == params.Profile.Did }} 14 14 {{ streakClass := "pill flex items-center gap-1 px-2 " }} 15 - @templates.BaseLayout(templates.BaseLayoutParams{Title: params.Profile.DisplayName}) { 15 + @layouts.Base(layouts.BaseParams{Title: params.Profile.DisplayName}) { 16 16 @partials.Header(partials.HeaderProps{User: params.User}) 17 17 <div class="container mx-auto max-w-6xl px-4 py-8"> 18 18 <div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-8">
+4 -4
internal/web/pages/resources.templ internal/server/views/resources.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/pages/partials" 5 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 6 ) 7 7 8 8 templ ResourcesPage(params ResourcesPageParams) { 9 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "resources"}) { 9 + @layouts.Base(layouts.BaseParams{Title: "resources"}) { 10 10 @partials.Header(partials.HeaderProps{User: params.User}) 11 11 <div class="container mx-auto px-4 py-8 max-w-2xl"> 12 12 <div class="flex flex-col sm:flex-row sm:items-center justify-between mb-8 gap-4">
+4 -4
internal/web/pages/stats.templ internal/server/views/stats.templ
··· 1 - package pages 1 + package views 2 2 3 3 import ( 4 - "yoten.app/internal/web/pages/partials" 5 - "yoten.app/internal/web/pages/templates" 4 + "yoten.app/internal/server/views/layouts" 5 + "yoten.app/internal/server/views/partials" 6 6 ) 7 7 8 8 templ StatsPage(params StatsPageParams) { 9 - @templates.BaseLayout(templates.BaseLayoutParams{Title: "stats"}) { 9 + @layouts.Base(layouts.BaseParams{Title: "stats"}) { 10 10 @partials.Header(partials.HeaderProps{User: params.User}) 11 11 <div class="container mx-auto max-w-6xl px-4 py-8"> 12 12 <div class="flex flex-col gap-8">
+2 -2
internal/web/pages/templates/base.templ internal/server/views/layouts/base.templ
··· 1 - package templates 1 + package layouts 2 2 3 - templ BaseLayout(params BaseLayoutParams) { 3 + templ Base(params BaseParams) { 4 4 <!DOCTYPE html> 5 5 <html lang="en"> 6 6 <head>
-11
internal/web/pages/templates/templates.go
··· 1 - package templates 2 - 3 - import "github.com/a-h/templ" 4 - 5 - type BaseLayoutParams struct { 6 - Title string 7 - } 8 - 9 - func BaseLayoutComponent(params BaseLayoutParams) templ.Component { 10 - return BaseLayout(params) 11 - }
internal/web/posthog/posthog.go internal/clients/posthog/posthog.go
+1 -1
internal/web/resolver/resolver.go internal/atproto/resolver.go
··· 1 - package resolver 1 + package atproto 2 2 3 3 import ( 4 4 "context"
+1 -1
internal/web/slice-utils.go internal/utils/slice.go
··· 1 - package web 1 + package utils 2 2 3 3 func Filter[T any](ss []T, test func(T) bool) (ret []T) { 4 4 for _, s := range ss {
+32 -31
internal/web/state/activity.go internal/server/handlers/activity.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "errors" ··· 13 13 "github.com/posthog/posthog-go" 14 14 15 15 "yoten.app/api/yoten" 16 - "yoten.app/internal/web" 17 - "yoten.app/internal/web/db" 18 - "yoten.app/internal/web/htmx" 19 - "yoten.app/internal/web/pages" 20 - ph "yoten.app/internal/web/posthog" 16 + "yoten.app/internal/atproto" 17 + "yoten.app/internal/clients/bsky" 18 + ph "yoten.app/internal/clients/posthog" 19 + "yoten.app/internal/db" 20 + "yoten.app/internal/server/htmx" 21 + "yoten.app/internal/server/views" 21 22 ) 22 23 23 24 const ( ··· 53 54 return activity, nil 54 55 } 55 56 56 - func (s *State) HandleNewActivityPage(w http.ResponseWriter, r *http.Request) { 57 - user, err := s.getUserWithBskyProfile(r) 57 + func (h *Handler) HandleNewActivityPage(w http.ResponseWriter, r *http.Request) { 58 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 58 59 if err != nil { 59 60 log.Println("failed to get logged-in user:", err) 60 61 htmx.HxRedirect(w, "/login") ··· 63 64 64 65 switch r.Method { 65 66 case http.MethodGet: 66 - pages.NewActivityPage(pages.NewActivityPageParams{ 67 + views.NewActivityPage(views.NewActivityPageParams{ 67 68 User: user, 68 - SortedCategories: s.computedData.SortedCategories, 69 + SortedCategories: h.ComputedData.SortedCategories, 69 70 }).Render(r.Context(), w) 70 71 case http.MethodPost: 71 - client, err := s.oauth.AuthorizedClient(r, w) 72 + client, err := h.Oauth.AuthorizedClient(r, w) 72 73 if err != nil { 73 74 log.Println("failed to get authorized client:", err) 74 75 htmx.HxRedirect(w, "/login") ··· 82 83 return 83 84 } 84 85 newActivity.Did = user.Did 85 - newActivity.Rkey = web.TID() 86 + newActivity.Rkey = atproto.TID() 86 87 newActivity.CreatedAt = time.Now() 87 88 88 89 if err := db.ValidateActivity(newActivity); err != nil { ··· 127 128 return 128 129 } 129 130 130 - err = SavePendingCreate(s, w, r, PendingActivityCreation, newActivity) 131 + err = SavePendingCreate(h, w, r, PendingActivityCreation, newActivity) 131 132 if err != nil { 132 133 log.Printf("failed to save yoten-session to add pending activity creation: %v", err) 133 134 } 134 135 135 - if !s.config.Core.Dev { 136 - err = s.posthog.Enqueue(posthog.Capture{ 136 + if !h.Config.Core.Dev { 137 + err = h.Posthog.Enqueue(posthog.Capture{ 137 138 DistinctId: user.Did, 138 139 Event: ph.ActivityDefRecordCreatedEvent, 139 140 Properties: posthog.NewProperties(). ··· 152 153 } 153 154 } 154 155 155 - func (s *State) HandleDeleteActivity(w http.ResponseWriter, r *http.Request) { 156 - user := s.oauth.GetUser(r) 156 + func (h *Handler) HandleDeleteActivity(w http.ResponseWriter, r *http.Request) { 157 + user := h.Oauth.GetUser(r) 157 158 if user == nil { 158 159 log.Println("failed to get logged-in user") 159 160 htmx.HxRedirect(w, "/login") 160 161 return 161 162 } 162 - client, err := s.oauth.AuthorizedClient(r, w) 163 + client, err := h.Oauth.AuthorizedClient(r, w) 163 164 if err != nil { 164 165 log.Println("failed to get authorized client:", err) 165 166 htmx.HxError(w, http.StatusUnauthorized, "Failed to delete activity, try again later.") ··· 169 170 switch r.Method { 170 171 case http.MethodDelete: 171 172 rkey := chi.URLParam(r, "rkey") 172 - activity, err := db.GetActivityByRkey(s.db, user.Did, rkey) 173 + activity, err := db.GetActivityByRkey(h.Db, user.Did, rkey) 173 174 if err != nil { 174 175 log.Println("failed to get activity from db:", err) 175 176 htmx.HxError(w, http.StatusInternalServerError, "Failed to delete activity, try again later.") ··· 193 194 return 194 195 } 195 196 196 - err = SavePendingDelete(s, w, r, PendingActivityDeletion, activity) 197 + err = SavePendingDelete(h, w, r, PendingActivityDeletion, activity) 197 198 if err != nil { 198 199 log.Printf("failed to save yoten-session to add pending activity deletion: %v", err) 199 200 } 200 201 201 - if !s.config.Core.Dev { 202 - err = s.posthog.Enqueue(posthog.Capture{ 202 + if !h.Config.Core.Dev { 203 + err = h.Posthog.Enqueue(posthog.Capture{ 203 204 DistinctId: user.Did, 204 205 Event: ph.ActivityDefRecordDeletedEvent, 205 206 Properties: posthog.NewProperties(). ··· 214 215 } 215 216 } 216 217 217 - func (s *State) HandleEditActivityPage(w http.ResponseWriter, r *http.Request) { 218 - user, err := s.getUserWithBskyProfile(r) 218 + func (h *Handler) HandleEditActivityPage(w http.ResponseWriter, r *http.Request) { 219 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 219 220 if err != nil { 220 221 log.Println("failed to get logged-in user:", err) 221 222 htmx.HxRedirect(w, "/login") ··· 223 224 } 224 225 225 226 rkey := chi.URLParam(r, "rkey") 226 - activity, err := db.GetActivityByRkey(s.db, user.Did, rkey) 227 + activity, err := db.GetActivityByRkey(h.Db, user.Did, rkey) 227 228 if err != nil { 228 229 log.Println("failed to get activity from db:", err) 229 230 htmx.HxError(w, http.StatusInternalServerError, "Failed to update activity, try again later.") ··· 238 239 239 240 switch r.Method { 240 241 case http.MethodGet: 241 - pages.EditActivityPage(pages.EditActivityPageParams{ 242 + views.EditActivityPage(views.EditActivityPageParams{ 242 243 User: user, 243 244 Activity: activity, 244 - SortedCategories: s.computedData.SortedCategories, 245 + SortedCategories: h.ComputedData.SortedCategories, 245 246 }).Render(r.Context(), w) 246 247 case http.MethodPost: 247 - client, err := s.oauth.AuthorizedClient(r, w) 248 + client, err := h.Oauth.AuthorizedClient(r, w) 248 249 if err != nil { 249 250 log.Println("failed to get authorized client:", err) 250 251 htmx.HxRedirect(w, "/login") ··· 310 311 return 311 312 } 312 313 313 - err = SavePendingUpdate(s, w, r, PendingActivityUpdates, updatedActivity) 314 + err = SavePendingUpdate(h, w, r, PendingActivityUpdates, updatedActivity) 314 315 if err != nil { 315 316 log.Printf("failed to save yoten-session to add pending activity updates: %v", err) 316 317 } 317 318 318 - if !s.config.Core.Dev { 319 - err = s.posthog.Enqueue(posthog.Capture{ 319 + if !h.Config.Core.Dev { 320 + err = h.Posthog.Enqueue(posthog.Capture{ 320 321 DistinctId: user.Did, 321 322 Event: ph.ActivityDefRecordEditedEvent, 322 323 Properties: posthog.NewProperties().
+49 -17
internal/web/state/follow.go internal/server/handlers/follow.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "log" ··· 10 10 "github.com/posthog/posthog-go" 11 11 12 12 "yoten.app/api/yoten" 13 - "yoten.app/internal/web" 14 - "yoten.app/internal/web/db" 15 - "yoten.app/internal/web/htmx" 16 - "yoten.app/internal/web/pages/partials" 17 - ph "yoten.app/internal/web/posthog" 13 + "yoten.app/internal/atproto" 14 + "yoten.app/internal/clients/bsky" 15 + ph "yoten.app/internal/clients/posthog" 16 + "yoten.app/internal/db" 17 + "yoten.app/internal/server/htmx" 18 + "yoten.app/internal/server/views/partials" 19 + "yoten.app/internal/utils" 18 20 ) 19 21 20 - func (s *State) HandleFollow(w http.ResponseWriter, r *http.Request) { 21 - client, err := s.oauth.AuthorizedClient(r, w) 22 + func (h *Handler) HandleFollow(w http.ResponseWriter, r *http.Request) { 23 + client, err := h.Oauth.AuthorizedClient(r, w) 22 24 if err != nil { 23 25 log.Println("failed to get authorized client:", err) 24 26 htmx.HxRedirect(w, "/login") 25 27 return 26 28 } 27 29 28 - user, err := s.getUserWithBskyProfile(r) 30 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 29 31 if err != nil { 30 32 log.Println("failed to get logged-in user:", err) 31 33 htmx.HxRedirect(w, "/login") ··· 38 40 return 39 41 } 40 42 41 - subjectIdent, err := s.idResolver.ResolveIdent(r.Context(), subject) 43 + subjectIdent, err := h.IdResolver.ResolveIdent(r.Context(), subject) 42 44 if err != nil { 43 45 log.Println("failed to follow, invalid did:", err) 44 46 htmx.HxError(w, http.StatusBadRequest, "Failed to follow profile, try again later.") ··· 54 56 switch r.Method { 55 57 case http.MethodPost: 56 58 createdAt := time.Now().Format(time.RFC3339) 57 - rkey := web.TID() 59 + rkey := atproto.TID() 58 60 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 59 61 Collection: yoten.GraphFollowNSID, 60 62 Repo: user.Did, ··· 71 73 return 72 74 } 73 75 74 - followStatus := db.GetFollowStatus(s.db, user.Did, subjectIdent.DID.String()) 76 + followStatus := db.GetFollowStatus(h.Db, user.Did, subjectIdent.DID.String()) 75 77 76 - if !s.config.Core.Dev { 77 - err = s.posthog.Enqueue(posthog.Capture{ 78 + if !h.Config.Core.Dev { 79 + err = h.Posthog.Enqueue(posthog.Capture{ 78 80 DistinctId: user.Did, 79 81 Event: ph.ProfileFollowedEvent, 80 82 Properties: posthog.NewProperties(). ··· 91 93 SubjectDid: subjectIdent.DID.String(), 92 94 }).Render(r.Context(), w) 93 95 case http.MethodDelete: 94 - follow, err := db.GetFollow(s.db, user.Did, subjectIdent.DID.String()) 96 + follow, err := db.GetFollow(h.Db, user.Did, subjectIdent.DID.String()) 95 97 if err != nil { 96 98 log.Println("failed to get follow relationship:", err) 97 99 htmx.HxError(w, http.StatusInternalServerError, "Failed to unfollow profile, try again later.") ··· 109 111 return 110 112 } 111 113 112 - if !s.config.Core.Dev { 113 - err = s.posthog.Enqueue(posthog.Capture{ 114 + if !h.Config.Core.Dev { 115 + err = h.Posthog.Enqueue(posthog.Capture{ 114 116 DistinctId: user.Did, 115 117 Event: ph.ProfileUnfollowedEvent, 116 118 Properties: posthog.NewProperties(). ··· 127 129 }).Render(r.Context(), w) 128 130 } 129 131 } 132 + 133 + func (h *Handler) GetHydratedFollowerProfiles(profiles []db.ProfileItem) ([]db.ProfileItemWithBskyProfile, error) { 134 + profileDids := utils.Map(profiles, func(profile db.ProfileItem) string { 135 + return profile.Did 136 + }) 137 + bskyProfiles, err := bsky.GetBskyProfiles(profileDids) 138 + if err != nil { 139 + return nil, err 140 + } 141 + 142 + var hydratedProfiles []db.ProfileItemWithBskyProfile 143 + for _, profile := range profiles { 144 + hydratedProfile := db.ProfileItemWithBskyProfile{ 145 + Did: profile.Did, 146 + DisplayName: profile.DisplayName, 147 + Level: profile.Level, 148 + StudySessionCount: profile.StudySessionCount, 149 + Languages: profile.Languages, 150 + FollowStatus: profile.FollowStatus, 151 + } 152 + 153 + if bp, ok := bskyProfiles[profile.Did]; ok { 154 + hydratedProfile.BskyProfile = bp 155 + } 156 + 157 + hydratedProfiles = append(hydratedProfiles, hydratedProfile) 158 + } 159 + 160 + return hydratedProfiles, nil 161 + }
+12 -12
internal/web/state/pending-ops.go internal/server/handlers/pending-ops.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "encoding/json" ··· 6 6 "net/http" 7 7 "slices" 8 8 9 - "yoten.app/internal/web" 9 + "yoten.app/internal/utils" 10 10 ) 11 11 12 12 type Rkeyer interface { 13 13 GetRkey() string 14 14 } 15 15 16 - func ApplyPendingChanges[T Rkeyer](s *State, w http.ResponseWriter, r *http.Request, items []T, createKey, updateKey, deleteKey string) ([]T, error) { 17 - yotenSession, err := s.oauth.Store.Get(r, "yoten-session") 16 + func ApplyPendingChanges[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, items []T, createKey, updateKey, deleteKey string) ([]T, error) { 17 + yotenSession, err := h.Oauth.Store.Get(r, "yoten-session") 18 18 if err != nil { 19 19 return items, err 20 20 } ··· 28 28 deletionMap[rkey] = true 29 29 } 30 30 31 - items = web.Filter(items, func(item T) bool { 31 + items = utils.Filter(items, func(item T) bool { 32 32 return !deletionMap[item.GetRkey()] 33 33 }) 34 34 delete(yotenSession.Values, deleteKey) ··· 73 73 return items, nil 74 74 } 75 75 76 - func SavePendingCreate[T any](s *State, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 77 - yotenSession, err := s.oauth.Store.Get(r, "yoten-session") 76 + func SavePendingCreate[T any](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 77 + yotenSession, err := h.Oauth.Store.Get(r, "yoten-session") 78 78 if err != nil { 79 79 return fmt.Errorf("failed to get yoten-session for pending create: %w", err) 80 80 } ··· 92 92 return nil 93 93 } 94 94 95 - func SavePendingUpdate[T Rkeyer](s *State, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 96 - yotenSession, err := s.oauth.Store.Get(r, "yoten-session") 95 + func SavePendingUpdate[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 96 + yotenSession, err := h.Oauth.Store.Get(r, "yoten-session") 97 97 if err != nil { 98 98 return fmt.Errorf("failed to get yoten-session for pending update: %w", err) 99 99 } ··· 117 117 return nil 118 118 } 119 119 120 - func SavePendingDelete[T Rkeyer](s *State, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 121 - yotenSession, err := s.oauth.Store.Get(r, "yoten-session") 120 + func SavePendingDelete[T Rkeyer](h *Handler, w http.ResponseWriter, r *http.Request, sessionKey string, item T) error { 121 + yotenSession, err := h.Oauth.Store.Get(r, "yoten-session") 122 122 if err != nil { 123 123 return fmt.Errorf("failed to get yoten-session for pending delete: %w", err) 124 124 } ··· 128 128 pendingDeletions = []string{} 129 129 } 130 130 131 - // Dont add if there's already a pending deletion. 131 + // Dont add if there'h already a pending deletion. 132 132 rkeyToDelete := item.GetRkey() 133 133 if slices.Contains(pendingDeletions, rkeyToDelete) { 134 134 return nil
+81 -70
internal/web/state/profile.go internal/server/handlers/profile.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "errors" ··· 16 16 "golang.org/x/sync/errgroup" 17 17 18 18 "yoten.app/api/yoten" 19 - "yoten.app/internal/web" 20 - "yoten.app/internal/web/bsky" 21 - "yoten.app/internal/web/db" 22 - "yoten.app/internal/web/htmx" 23 - "yoten.app/internal/web/pages" 24 - "yoten.app/internal/web/pages/partials" 25 - ph "yoten.app/internal/web/posthog" 19 + "yoten.app/internal/clients/bsky" 20 + ph "yoten.app/internal/clients/posthog" 21 + "yoten.app/internal/db" 22 + "yoten.app/internal/server/htmx" 23 + "yoten.app/internal/server/views" 24 + "yoten.app/internal/server/views/partials" 25 + "yoten.app/internal/types" 26 + "yoten.app/internal/utils" 26 27 ) 27 28 28 29 const ( ··· 60 61 return profile, nil 61 62 } 62 63 63 - func (s *State) HandleProfilePage(w http.ResponseWriter, r *http.Request) { 64 + func (h *Handler) HandleProfilePage(w http.ResponseWriter, r *http.Request) { 64 65 didOrHandle := chi.URLParam(r, "user") 65 66 if didOrHandle == "" { 66 67 http.Error(w, "Bad request", http.StatusBadRequest) ··· 70 71 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 71 72 if !ok { 72 73 w.WriteHeader(http.StatusNotFound) 73 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 74 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 74 75 return 75 76 } 76 77 77 - user, _ := s.getUserWithBskyProfile(r) 78 + user, _ := bsky.GetUserWithBskyProfile(h.Oauth, r) 78 79 profileDid := ident.DID.String() 79 80 80 - var bskyProfile bsky.BskyProfile 81 + var bskyProfile types.BskyProfile 81 82 var profile db.Profile 82 83 var totalStudyTime time.Duration 83 84 var totalStudySessions int64 ··· 94 95 }) 95 96 96 97 g.Go(func() (err error) { 97 - profile, err = s.GetUserProfileWithAvatar(profileDid) 98 + profile, err = h.GetUserProfileWithAvatar(profileDid) 98 99 if err != nil { 99 100 return err 100 101 } 101 102 102 - totalStudyTime, err = db.GetTotalStudyTime(s.db, profileDid) 103 + totalStudyTime, err = db.GetTotalStudyTime(h.Db, profileDid) 103 104 if err != nil { 104 105 log.Println("failed to get total study time:", err) 105 106 } 106 107 107 - totalStudySessions, _ = db.GetTotalStudySessions(s.db, profileDid) 108 + totalStudySessions, _ = db.GetTotalStudySessions(h.Db, profileDid) 108 109 if err != nil { 109 110 log.Println("failed to get total study study sessions:", err) 110 111 } 111 112 112 - followers, following, _ = db.GetFollowerFollowingCount(s.db, profileDid) 113 + followers, following, _ = db.GetFollowerFollowingCount(h.Db, profileDid) 113 114 if err != nil { 114 115 log.Println("failed to get follow stats:", err) 115 116 } 116 117 117 - streak, _ = db.GetCurrentStreak(s.db, profileDid) 118 + streak, _ = db.GetCurrentStreak(h.Db, profileDid) 118 119 if err != nil { 119 120 log.Println("failed to get streak:", err) 120 121 } 121 122 122 123 if user != nil { 123 - followStatus = db.GetFollowStatus(s.db, user.Did, profileDid) 124 + followStatus = db.GetFollowStatus(h.Db, user.Did, profileDid) 124 125 } 125 126 126 127 return nil ··· 132 133 return 133 134 } 134 135 135 - if !s.config.Core.Dev { 136 + if !h.Config.Core.Dev { 136 137 properties := posthog.NewProperties(). 137 138 Set("is_own_profile", false). 138 139 Set("viewed_profile_did", profile.Did) ··· 157 158 } 158 159 } 159 160 160 - err := s.posthog.Enqueue(capture) 161 + err := h.Posthog.Enqueue(capture) 161 162 if err != nil { 162 163 log.Println("failed to enqueue posthog event:", err) 163 164 } 164 165 } 165 166 166 - pages.ProfilePage(pages.ProfilePageParams{ 167 + views.ProfilePage(views.ProfilePageParams{ 167 168 User: user, 168 169 Profile: profile, 169 170 BskyProfile: bskyProfile, ··· 176 177 }).Render(r.Context(), w) 177 178 } 178 179 179 - func (s *State) HandleEditProfilePage(w http.ResponseWriter, r *http.Request) { 180 - user, err := s.getUserWithBskyProfile(r) 180 + func (h *Handler) HandleEditProfilePage(w http.ResponseWriter, r *http.Request) { 181 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 181 182 if err != nil { 182 183 log.Println("failed to get logged-in user:", err) 183 184 htmx.HxRedirect(w, "/login") 184 185 return 185 186 } 186 187 187 - profile, err := s.GetUserProfileWithAvatar(user.Did) 188 + profile, err := h.GetUserProfileWithAvatar(user.Did) 188 189 if err != nil { 189 190 log.Printf("failed to find %s in db: %s", user.Did, err) 190 191 w.WriteHeader(http.StatusNotFound) 191 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 192 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 192 193 return 193 194 } 194 195 ··· 200 201 201 202 switch r.Method { 202 203 case http.MethodGet: 203 - profileLanguageCodes := web.Map(profile.Languages, func(language db.Language) string { 204 + profileLanguageCodes := utils.Map(profile.Languages, func(language db.Language) string { 204 205 return string(language.Code) 205 206 }) 206 207 207 - pages.EditProfilePage(pages.EditProfilePageParams{ 208 + views.EditProfilePage(views.EditProfilePageParams{ 208 209 Profile: profile, 209 210 User: user, 210 - AllLanguages: s.computedData.SortedLanguages, 211 + AllLanguages: h.ComputedData.SortedLanguages, 211 212 InitialSelectedLanguages: profileLanguageCodes, 212 213 }).Render(r.Context(), w) 213 214 case http.MethodPost: 214 - client, err := s.oauth.AuthorizedClient(r, w) 215 + client, err := h.Oauth.AuthorizedClient(r, w) 215 216 if err != nil { 216 217 log.Println("failed to get authorized client:", err) 217 218 htmx.HxRedirect(w, "/login") ··· 284 285 return 285 286 } 286 287 287 - err = SavePendingUpdate(s, w, r, PendingProfileUpdate, profile) 288 + err = SavePendingUpdate(h, w, r, PendingProfileUpdate, profile) 288 289 if err != nil { 289 290 log.Printf("failed to save yoten-session to add pending profile update: %v", err) 290 291 } 291 292 292 - if !s.config.Core.Dev { 293 + if !h.Config.Core.Dev { 293 294 properties := posthog.NewProperties(). 294 295 Set("display_name", updatedProfile.DisplayName). 295 296 Set("bio_provided", len(updatedProfile.Description) > 0). ··· 302 303 Set("created_at", updatedProfile.CreatedAt.Format(time.RFC3339)), 303 304 ) 304 305 305 - err = s.posthog.Enqueue(posthog.Identify{ 306 + err = h.Posthog.Enqueue(posthog.Identify{ 306 307 DistinctId: user.Did, 307 308 Properties: properties, 308 309 }) ··· 310 311 log.Println("failed to enqueue posthog identify event:", err) 311 312 } 312 313 313 - err = s.posthog.Enqueue(posthog.Capture{ 314 + err = h.Posthog.Enqueue(posthog.Capture{ 314 315 DistinctId: user.Did, 315 316 Event: ph.ProfileRecordEditedEvent, 316 317 }) ··· 323 324 } 324 325 } 325 326 326 - func (s *State) HandleResourcesPage(w http.ResponseWriter, r *http.Request) { 327 - user, err := s.getUserWithBskyProfile(r) 327 + func (h *Handler) HandleResourcesPage(w http.ResponseWriter, r *http.Request) { 328 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 328 329 if err != nil { 329 330 log.Println("failed to get logged-in user:", err) 330 331 htmx.HxRedirect(w, "/login") 331 332 return 332 333 } 333 334 334 - resources, err := db.GetResourcesByDid(s.db, user.Did) 335 + resources, err := db.GetResourcesByDid(h.Db, user.Did) 335 336 if err != nil { 336 337 log.Println("failed to get resources:", err) 337 338 htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile resources, try again later.") 338 339 return 339 340 } 340 341 341 - resources, err = ApplyPendingChanges(s, w, r, resources, PendingResourceCreation, PendingResourceUpdates, PendingResourceDeletion) 342 + resources, err = ApplyPendingChanges(h, w, r, resources, PendingResourceCreation, PendingResourceUpdates, PendingResourceDeletion) 342 343 if err != nil { 343 344 log.Printf("failed to save yoten-session after processing pending changes: %v", err) 344 345 } 345 346 346 - activeResources := web.Filter(resources, func(resource db.Resource) bool { 347 + activeResources := utils.Filter(resources, func(resource db.Resource) bool { 347 348 return resource.Status != db.Deleted 348 349 }) 349 350 350 - pages.ResourcesPage(pages.ResourcesPageParams{ 351 + views.ResourcesPage(views.ResourcesPageParams{ 351 352 User: user, 352 353 Resources: activeResources, 353 354 }).Render(r.Context(), w) 354 355 } 355 356 356 - func (s *State) HandleActivitiesPage(w http.ResponseWriter, r *http.Request) { 357 - user, err := s.getUserWithBskyProfile(r) 357 + func (h *Handler) HandleActivitiesPage(w http.ResponseWriter, r *http.Request) { 358 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 358 359 if err != nil { 359 360 log.Println("failed to get logged-in user:", err) 360 361 htmx.HxRedirect(w, "/login") 361 362 return 362 363 } 363 364 364 - activities, err := db.GetActivitiesByDid(s.db, user.Did) 365 + activities, err := db.GetActivitiesByDid(h.Db, user.Did) 365 366 if err != nil { 366 367 log.Println("failed to get activities:", err) 367 368 htmx.HxError(w, http.StatusInternalServerError, "Failed to retrieve profile activities, try again later.") 368 369 return 369 370 } 370 371 371 - activities, err = ApplyPendingChanges(s, w, r, activities, PendingActivityCreation, PendingActivityUpdates, PendingActivityDeletion) 372 + activities, err = ApplyPendingChanges(h, w, r, activities, PendingActivityCreation, PendingActivityUpdates, PendingActivityDeletion) 372 373 if err != nil { 373 374 log.Printf("failed to save yoten-session after processing pending changes: %v", err) 374 375 } 375 376 376 - activeActivities := web.Filter(activities, func(activity db.Activity) bool { 377 + activeActivities := utils.Filter(activities, func(activity db.Activity) bool { 377 378 return activity.Status != db.Deleted 378 379 }) 379 380 380 - pages.ActivitiesPage(pages.ActivitiesPageParams{ 381 + views.ActivitiesPage(views.ActivitiesPageParams{ 381 382 User: user, 382 383 Activities: activeActivities, 383 384 }).Render(r.Context(), w) 384 385 } 385 386 386 - func (s *State) GetUserProfileWithAvatar(did string) (db.Profile, error) { 387 - profile, err := db.GetProfile(s.db, did) 387 + func (h *Handler) GetUserProfileWithAvatar(did string) (db.Profile, error) { 388 + profile, err := db.GetProfile(h.Db, did) 388 389 if err != nil { 389 390 return db.Profile{}, fmt.Errorf("failed to get profile: %w", err) 390 391 } ··· 398 399 return profile, nil 399 400 } 400 401 401 - func (s *State) HandleProfileFeed(w http.ResponseWriter, r *http.Request) { 402 + func (h *Handler) HandleProfileFeed(w http.ResponseWriter, r *http.Request) { 402 403 didOrHandle := chi.URLParam(r, "user") 403 404 if didOrHandle == "" { 404 405 http.Error(w, "Bad request", http.StatusBadRequest) ··· 408 409 ident, ok := r.Context().Value("resolvedId").(identity.Identity) 409 410 if !ok { 410 411 w.WriteHeader(http.StatusNotFound) 411 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 412 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 412 413 return 413 414 } 414 415 415 - profile, err := s.GetUserProfileWithAvatar(ident.DID.String()) 416 + profile, err := h.GetUserProfileWithAvatar(ident.DID.String()) 416 417 if err != nil { 417 418 log.Printf("failed to find %s in db: %s", ident.DID.String(), err) 418 419 w.WriteHeader(http.StatusNotFound) 419 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 420 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 420 421 return 421 422 } 422 423 ··· 424 425 if err != nil { 425 426 log.Println("failed to get bsky profile:", err) 426 427 w.WriteHeader(http.StatusNotFound) 427 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 428 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 428 429 return 429 430 } 430 431 ··· 444 445 const pageSize = 10 445 446 offset := (page - 1) * pageSize 446 447 447 - sessions, err := db.GetStudySessionLogs(s.db, ident.DID.String(), pageSize+1, int(offset)) 448 + sessions, err := db.GetStudySessionLogs(h.Db, ident.DID.String(), pageSize+1, int(offset)) 448 449 if err != nil { 449 450 log.Println("failed to get study sessions:", err) 450 451 w.WriteHeader(http.StatusNotFound) 451 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 452 + views.NotFoundPage(views.NotFoundPageParams{}).Render(r.Context(), w) 452 453 return 453 454 } 454 455 455 - sessions, err = ApplyPendingChanges(s, w, r, sessions, PendingStudySessionCreation, PendingStudySessionUpdates, PendingStudySessionDeletion) 456 + sessions, err = ApplyPendingChanges(h, w, r, sessions, PendingStudySessionCreation, PendingStudySessionUpdates, PendingStudySessionDeletion) 456 457 if err != nil { 457 458 log.Printf("failed to save yoten-session after processing pending changes: %v", err) 458 459 } ··· 476 477 477 478 isSelf := false 478 479 479 - user, err := s.getUserWithBskyProfile(r) 480 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 480 481 if err == nil { 481 482 isSelf = user.Did == profile.Did 482 483 } ··· 491 492 }).Render(r.Context(), w) 492 493 } 493 494 494 - func (s *State) HandleFriendsPage(w http.ResponseWriter, r *http.Request) { 495 - user, err := s.getUserWithBskyProfile(r) 495 + func (h *Handler) HandleFriendsPage(w http.ResponseWriter, r *http.Request) { 496 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 496 497 if err != nil { 497 498 log.Println("failed to get logged-in user:", err) 498 499 htmx.HxRedirect(w, "/login") 499 500 return 500 501 } 501 502 502 - followers, following, err := db.GetFollowerFollowingCount(s.db, user.Did) 503 + followers, following, err := db.GetFollowerFollowingCount(h.Db, user.Did) 503 504 if err != nil { 504 505 log.Printf("getting follow stats repos for %s: %s", user.Did, err) 505 506 } 506 507 507 - pages.FriendsPage(pages.FriendsPageParams{ 508 + views.FriendsPage(views.FriendsPageParams{ 508 509 User: user, 509 510 Followers: followers, 510 511 Following: following, 511 512 }).Render(r.Context(), w) 512 513 } 513 514 514 - func (s *State) HandleFriendsFeed(w http.ResponseWriter, r *http.Request) { 515 - user, err := s.getUserWithBskyProfile(r) 515 + func (h *Handler) HandleFriendsFeed(w http.ResponseWriter, r *http.Request) { 516 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 516 517 if err != nil { 517 518 log.Println("failed to get logged-in user") 518 519 htmx.HxRedirect(w, "/login") ··· 540 541 541 542 switch r.Method { 542 543 case http.MethodGet: 543 - feed := []db.ProfileItem{} 544 - var err error 544 + bskyHydratedFeed := []db.ProfileItemWithBskyProfile{} 545 545 if mode == partials.Following { 546 - feed, err = db.GetFollowing(s.db, user.Did, pageSize+1, int(offset)) 546 + feed, err := db.GetFollowing(h.Db, user.Did, pageSize+1, int(offset)) 547 547 if err != nil { 548 548 log.Println("failed to get following list:", err) 549 549 htmx.HxError(w, http.StatusInternalServerError, "Failed to get following list, try again later.") 550 550 return 551 551 } 552 + bskyHydratedFeed, err = h.GetHydratedFollowerProfiles(feed) 553 + if err != nil { 554 + log.Println("failed to hydrate bsky profiles:", err) 555 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get following list, try again later.") 556 + return 557 + } 552 558 } else { 553 - 554 - feed, err = db.GetFollowers(s.db, user.Did, pageSize+1, int(offset)) 559 + feed, err := db.GetFollowers(h.Db, user.Did, pageSize+1, int(offset)) 555 560 if err != nil { 556 561 log.Println("failed to get followers list:", err) 557 562 htmx.HxError(w, http.StatusInternalServerError, "Failed to get followers list, try again later.") 558 563 return 559 564 } 565 + bskyHydratedFeed, err = h.GetHydratedFollowerProfiles(feed) 566 + if err != nil { 567 + log.Println("failed to hydrate bsky profiles:", err) 568 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get following list, try again later.") 569 + return 570 + } 560 571 } 561 572 562 573 nextPage := 0 563 - if len(feed) > pageSize { 574 + if len(bskyHydratedFeed) > pageSize { 564 575 nextPage = int(page + 1) 565 - feed = feed[:pageSize] 576 + bskyHydratedFeed = bskyHydratedFeed[:pageSize] 566 577 } 567 578 568 579 partials.FriendsFeed(partials.FriendsFeedProps{ 569 580 User: user, 570 - Feed: feed, 581 + Feed: bskyHydratedFeed, 571 582 FeedMode: mode, 572 583 NextPage: nextPage, 573 584 }).Render(r.Context(), w)
+19 -17
internal/web/state/reaction.go internal/server/handlers/reaction.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "log" ··· 10 10 comatproto "github.com/bluesky-social/indigo/api/atproto" 11 11 lexutil "github.com/bluesky-social/indigo/lex/util" 12 12 "github.com/posthog/posthog-go" 13 + 13 14 "yoten.app/api/yoten" 14 - "yoten.app/internal/web" 15 - "yoten.app/internal/web/db" 16 - "yoten.app/internal/web/htmx" 17 - "yoten.app/internal/web/pages/partials" 18 - ph "yoten.app/internal/web/posthog" 15 + "yoten.app/internal/atproto" 16 + "yoten.app/internal/clients/bsky" 17 + ph "yoten.app/internal/clients/posthog" 18 + "yoten.app/internal/db" 19 + "yoten.app/internal/server/htmx" 20 + "yoten.app/internal/server/views/partials" 19 21 ) 20 22 21 - func (s *State) HandleReaction(w http.ResponseWriter, r *http.Request) { 22 - client, err := s.oauth.AuthorizedClient(r, w) 23 + func (h *Handler) HandleReaction(w http.ResponseWriter, r *http.Request) { 24 + client, err := h.Oauth.AuthorizedClient(r, w) 23 25 if err != nil { 24 26 log.Println("failed to get authorized client:", err) 25 27 htmx.HxRedirect(w, "/login") 26 28 return 27 29 } 28 30 29 - user, err := s.getUserWithBskyProfile(r) 31 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 30 32 if err != nil { 31 33 log.Println("failed to get logged-in user:", err) 32 34 htmx.HxRedirect(w, "/login") ··· 76 78 return 77 79 } 78 80 79 - reactionEvents, err := db.GetReactionsForSession(s.db, subjectDid, subjectRkey) 81 + reactionEvents, err := db.GetReactionsForSession(h.Db, subjectDid, subjectRkey) 80 82 if err != nil { 81 83 log.Println("failed to get reactions for study session from db:", err) 82 84 htmx.HxError(w, http.StatusInternalServerError, "Failed to get global study session feed, try again later.") ··· 85 87 86 88 switch r.Method { 87 89 case http.MethodPost: 88 - reactionEvent, err := db.GetReactionEvent(s.db, user.Did, session, reaction.ID) 90 + reactionEvent, err := db.GetReactionEvent(h.Db, user.Did, session, reaction.ID) 89 91 if err != nil { 90 92 log.Println("failed to get reaction event from db:", err) 91 93 htmx.HxError(w, http.StatusInternalServerError, "Failed to add reaction, try again later.") ··· 98 100 } 99 101 100 102 createdAt := time.Now().Format(time.RFC3339) 101 - rkey := web.TID() 103 + rkey := atproto.TID() 102 104 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 103 105 Collection: yoten.FeedReactionNSID, 104 106 Repo: user.Did, ··· 116 118 return 117 119 } 118 120 119 - if !s.config.Core.Dev { 120 - err = s.posthog.Enqueue(posthog.Capture{ 121 + if !h.Config.Core.Dev { 122 + err = h.Posthog.Enqueue(posthog.Capture{ 121 123 DistinctId: user.Did, 122 124 Event: ph.ReactionRecordCreatedEvent, 123 125 Properties: posthog.NewProperties(). ··· 149 151 ReactionEvents: reactionEvents, 150 152 }).Render(r.Context(), w) 151 153 case http.MethodDelete: 152 - reactionEvent, err := db.GetReactionEvent(s.db, user.Did, session, reaction.ID) 154 + reactionEvent, err := db.GetReactionEvent(h.Db, user.Did, session, reaction.ID) 153 155 if err != nil { 154 156 log.Println("failed to get reaction event from db:", err) 155 157 htmx.HxError(w, http.StatusInternalServerError, "Failed to remove reaction, try again later.") ··· 167 169 return 168 170 } 169 171 170 - if !s.config.Core.Dev { 171 - err = s.posthog.Enqueue(posthog.Capture{ 172 + if !h.Config.Core.Dev { 173 + err = h.Posthog.Enqueue(posthog.Capture{ 172 174 DistinctId: user.Did, 173 175 Event: ph.ReactionRecordDeletedEvent, 174 176 Properties: posthog.NewProperties().
+45 -44
internal/web/state/resource.go internal/server/handlers/resource.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "errors" ··· 13 13 "github.com/posthog/posthog-go" 14 14 15 15 "yoten.app/api/yoten" 16 - "yoten.app/internal/web" 17 - "yoten.app/internal/web/db" 18 - "yoten.app/internal/web/google" 19 - "yoten.app/internal/web/htmx" 20 - "yoten.app/internal/web/pages" 21 - ph "yoten.app/internal/web/posthog" 16 + "yoten.app/internal/atproto" 17 + "yoten.app/internal/clients/bsky" 18 + "yoten.app/internal/clients/google" 19 + ph "yoten.app/internal/clients/posthog" 20 + "yoten.app/internal/db" 21 + "yoten.app/internal/server/htmx" 22 + "yoten.app/internal/server/views" 22 23 ) 23 24 24 25 const ( ··· 61 62 return resource, nil 62 63 } 63 64 64 - func (s *State) HandleNewResourcePage(w http.ResponseWriter, r *http.Request) { 65 - user, err := s.getUserWithBskyProfile(r) 65 + func (h *Handler) HandleNewResourcePage(w http.ResponseWriter, r *http.Request) { 66 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 66 67 if err != nil { 67 68 log.Println("failed to get logged-in user:", err) 68 69 htmx.HxRedirect(w, "/login") ··· 71 72 72 73 switch r.Method { 73 74 case http.MethodGet: 74 - pages.NewResourcePage(pages.NewResourcePageParams{ 75 + views.NewResourcePage(views.NewResourcePageParams{ 75 76 User: user, 76 - SortedResourceTypes: s.computedData.SortedResourceTypes, 77 + SortedResourceTypes: h.ComputedData.SortedResourceTypes, 77 78 }).Render(r.Context(), w) 78 79 case http.MethodPost: 79 - client, err := s.oauth.AuthorizedClient(r, w) 80 + client, err := h.Oauth.AuthorizedClient(r, w) 80 81 if err != nil { 81 82 log.Println("failed to get authorized client:", err) 82 83 htmx.HxRedirect(w, "/login") ··· 90 91 return 91 92 } 92 93 newResource.Did = user.Did 93 - newResource.Rkey = web.TID() 94 + newResource.Rkey = atproto.TID() 94 95 newResource.CreatedAt = time.Now() 95 96 96 97 if err := db.ValidateResource(newResource); err != nil { ··· 120 121 log.Println("invalid resource definition:", linkCheckResult.Err) 121 122 switch { 122 123 case errors.Is(linkCheckResult.Err, google.ErrResourceLinkSketchy): 123 - if !s.config.Core.Dev { 124 - s.posthog.Enqueue(posthog.Capture{ 124 + if !h.Config.Core.Dev { 125 + h.Posthog.Enqueue(posthog.Capture{ 125 126 DistinctId: user.Did, 126 127 Event: "link_safety_checked", 127 128 Properties: posthog.NewProperties(). ··· 135 136 case errors.Is(linkCheckResult.Err, google.ErrResourceLinkInvalid), 136 137 errors.Is(linkCheckResult.Err, google.ErrResourceLinkApiError), 137 138 errors.Is(linkCheckResult.Err, google.ErrResourceLinkFailedToOpen): 138 - if !s.config.Core.Dev { 139 - s.posthog.Enqueue(posthog.Capture{ 139 + if !h.Config.Core.Dev { 140 + h.Posthog.Enqueue(posthog.Capture{ 140 141 DistinctId: user.Did, 141 142 Event: "link_safety_checked", 142 143 Properties: posthog.NewProperties(). ··· 152 153 return 153 154 } 154 155 155 - if !s.config.Core.Dev { 156 - s.posthog.Enqueue(posthog.Capture{ 156 + if !h.Config.Core.Dev { 157 + h.Posthog.Enqueue(posthog.Capture{ 157 158 DistinctId: user.Did, 158 159 Event: "link_safety_checked", 159 160 Properties: posthog.NewProperties(). ··· 190 191 return 191 192 } 192 193 193 - err = SavePendingCreate(s, w, r, PendingResourceCreation, newResource) 194 + err = SavePendingCreate(h, w, r, PendingResourceCreation, newResource) 194 195 if err != nil { 195 196 log.Printf("failed to save yoten-session to add pending resource creation: %v", err) 196 197 } 197 198 198 - if !s.config.Core.Dev { 199 - err = s.posthog.Enqueue(posthog.Capture{ 199 + if !h.Config.Core.Dev { 200 + err = h.Posthog.Enqueue(posthog.Capture{ 200 201 DistinctId: user.Did, 201 202 Event: ph.ResourceRecordCreatedEvent, 202 203 Properties: posthog.NewProperties(). ··· 215 216 } 216 217 } 217 218 218 - func (s *State) HandleDeleteResource(w http.ResponseWriter, r *http.Request) { 219 - user := s.oauth.GetUser(r) 219 + func (h *Handler) HandleDeleteResource(w http.ResponseWriter, r *http.Request) { 220 + user := h.Oauth.GetUser(r) 220 221 if user == nil { 221 222 log.Println("failed to get logged-in user") 222 223 htmx.HxRedirect(w, "/login") 223 224 return 224 225 } 225 - client, err := s.oauth.AuthorizedClient(r, w) 226 + client, err := h.Oauth.AuthorizedClient(r, w) 226 227 if err != nil { 227 228 log.Println("failed to get authorized client:", err) 228 229 htmx.HxError(w, http.StatusUnauthorized, "Failed to delete resource, try again later.") ··· 232 233 switch r.Method { 233 234 case http.MethodDelete: 234 235 rkey := chi.URLParam(r, "rkey") 235 - resource, err := db.GetResourceByRkey(s.db, user.Did, rkey) 236 + resource, err := db.GetResourceByRkey(h.Db, user.Did, rkey) 236 237 if err != nil { 237 238 log.Println("failed to get resource from db:", err) 238 239 htmx.HxError(w, http.StatusInternalServerError, "Failed to delete resource, try again later.") ··· 256 257 return 257 258 } 258 259 259 - err = SavePendingDelete(s, w, r, PendingResourceDeletion, resource) 260 + err = SavePendingDelete(h, w, r, PendingResourceDeletion, resource) 260 261 if err != nil { 261 262 log.Printf("failed to save yoten-session to add pending resource deletion: %v", err) 262 263 } 263 264 264 - if !s.config.Core.Dev { 265 - err = s.posthog.Enqueue(posthog.Capture{ 265 + if !h.Config.Core.Dev { 266 + err = h.Posthog.Enqueue(posthog.Capture{ 266 267 DistinctId: user.Did, 267 268 Event: ph.ResourceRecordDeletedEvent, 268 269 Properties: posthog.NewProperties(). ··· 278 279 } 279 280 } 280 281 281 - func (s *State) HandleEditResourcePage(w http.ResponseWriter, r *http.Request) { 282 - user, err := s.getUserWithBskyProfile(r) 282 + func (h *Handler) HandleEditResourcePage(w http.ResponseWriter, r *http.Request) { 283 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 283 284 if err != nil { 284 285 log.Println("failed to get logged-in user:", err) 285 286 htmx.HxRedirect(w, "/login") ··· 287 288 } 288 289 289 290 rkey := chi.URLParam(r, "rkey") 290 - resource, err := db.GetResourceByRkey(s.db, user.Did, rkey) 291 + resource, err := db.GetResourceByRkey(h.Db, user.Did, rkey) 291 292 if err != nil { 292 293 log.Println("failed to get resource from db:", err) 293 294 htmx.HxError(w, http.StatusInternalServerError, "Failed to update resource, try again later.") ··· 302 303 303 304 switch r.Method { 304 305 case http.MethodGet: 305 - pages.EditResourcePage(pages.EditResourcePageParams{ 306 + views.EditResourcePage(views.EditResourcePageParams{ 306 307 User: user, 307 308 Resource: resource, 308 - SortedResourceTypes: s.computedData.SortedResourceTypes, 309 + SortedResourceTypes: h.ComputedData.SortedResourceTypes, 309 310 }).Render(r.Context(), w) 310 311 case http.MethodPost: 311 - client, err := s.oauth.AuthorizedClient(r, w) 312 + client, err := h.Oauth.AuthorizedClient(r, w) 312 313 if err != nil { 313 314 log.Println("failed to get authorized client:", err) 314 315 htmx.HxRedirect(w, "/login") ··· 353 354 log.Println("invalid resource definition:", linkCheckResult.Err) 354 355 switch { 355 356 case errors.Is(linkCheckResult.Err, google.ErrResourceLinkSketchy): 356 - if !s.config.Core.Dev { 357 - s.posthog.Enqueue(posthog.Capture{ 357 + if !h.Config.Core.Dev { 358 + h.Posthog.Enqueue(posthog.Capture{ 358 359 DistinctId: user.Did, 359 360 Event: "link_safety_checked", 360 361 Properties: posthog.NewProperties(). ··· 368 369 case errors.Is(linkCheckResult.Err, google.ErrResourceLinkInvalid), 369 370 errors.Is(linkCheckResult.Err, google.ErrResourceLinkApiError), 370 371 errors.Is(linkCheckResult.Err, google.ErrResourceLinkFailedToOpen): 371 - if !s.config.Core.Dev { 372 - s.posthog.Enqueue(posthog.Capture{ 372 + if !h.Config.Core.Dev { 373 + h.Posthog.Enqueue(posthog.Capture{ 373 374 DistinctId: user.Did, 374 375 Event: "link_safety_checked", 375 376 Properties: posthog.NewProperties(). ··· 385 386 return 386 387 } 387 388 388 - if !s.config.Core.Dev { 389 - s.posthog.Enqueue(posthog.Capture{ 389 + if !h.Config.Core.Dev { 390 + h.Posthog.Enqueue(posthog.Capture{ 390 391 DistinctId: user.Did, 391 392 Event: "link_safety_checked", 392 393 Properties: posthog.NewProperties(). ··· 430 431 return 431 432 } 432 433 433 - err = SavePendingUpdate(s, w, r, PendingResourceUpdates, updatedResource) 434 + err = SavePendingUpdate(h, w, r, PendingResourceUpdates, updatedResource) 434 435 if err != nil { 435 436 log.Printf("failed to save yoten-session to add pending resource updates: %v", err) 436 437 } 437 438 438 - if !s.config.Core.Dev { 439 - err = s.posthog.Enqueue(posthog.Capture{ 439 + if !h.Config.Core.Dev { 440 + err = h.Posthog.Enqueue(posthog.Capture{ 440 441 DistinctId: user.Did, 441 442 Event: ph.ResourceRecordEditedEvent, 442 443 Properties: posthog.NewProperties().
-130
internal/web/state/router.go
··· 1 - package state 2 - 3 - import ( 4 - "net/http" 5 - "strings" 6 - 7 - "github.com/go-chi/chi/v5" 8 - "github.com/gorilla/sessions" 9 - 10 - "yoten.app/internal/web/middleware" 11 - oauthhandler "yoten.app/internal/web/oauth/handler" 12 - "yoten.app/internal/web/pages" 13 - ) 14 - 15 - func (s *State) Router() http.Handler { 16 - router := chi.NewRouter() 17 - middleware := middleware.New( 18 - s.oauth, 19 - s.db, 20 - s.idResolver, 21 - ) 22 - 23 - router.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { 24 - pat := chi.URLParam(r, "*") 25 - if strings.HasPrefix(pat, "did:") || strings.HasPrefix(pat, "@") { 26 - s.UserRouter(&middleware).ServeHTTP(w, r) 27 - } else { 28 - s.StandardRouter(&middleware).ServeHTTP(w, r) 29 - } 30 - }) 31 - 32 - return router 33 - } 34 - 35 - func (s *State) StandardRouter(mw *middleware.Middleware) http.Handler { 36 - r := chi.NewRouter() 37 - 38 - r.Handle("/static/*", s.HandleStatic()) 39 - r.Get("/", s.HandleIndexPage) 40 - r.Get("/feed", s.HandleStudySessionFeed) 41 - 42 - r.Mount("/", s.OAuthRouter()) 43 - 44 - r.Route("/friends", func(r chi.Router) { 45 - r.Use(middleware.AuthMiddleware(s.oauth)) 46 - r.Get("/", s.HandleFriendsPage) 47 - r.Get("/feed", s.HandleFriendsFeed) 48 - }) 49 - 50 - r.Route("/profile", func(r chi.Router) { 51 - r.Use(middleware.AuthMiddleware(s.oauth)) 52 - r.Get("/edit", s.HandleEditProfilePage) 53 - r.Post("/edit", s.HandleEditProfilePage) 54 - r.Get("/activities", s.HandleActivitiesPage) 55 - r.Get("/resources", s.HandleResourcesPage) 56 - }) 57 - 58 - r.Route("/follow", func(r chi.Router) { 59 - r.Use(middleware.AuthMiddleware(s.oauth)) 60 - r.Post("/", s.HandleFollow) 61 - r.Delete("/", s.HandleFollow) 62 - }) 63 - 64 - r.Route("/resource", func(r chi.Router) { 65 - r.Use(middleware.AuthMiddleware(s.oauth)) 66 - r.Get("/new", s.HandleNewResourcePage) 67 - r.Post("/new", s.HandleNewResourcePage) 68 - r.Get("/edit/{rkey}", s.HandleEditResourcePage) 69 - r.Post("/edit/{rkey}", s.HandleEditResourcePage) 70 - r.Delete("/{rkey}", s.HandleDeleteResource) 71 - }) 72 - 73 - r.Route("/activity", func(r chi.Router) { 74 - r.Use(middleware.AuthMiddleware(s.oauth)) 75 - r.Get("/new", s.HandleNewActivityPage) 76 - r.Post("/new", s.HandleNewActivityPage) 77 - r.Post("/edit/{rkey}", s.HandleEditActivityPage) 78 - r.Get("/edit/{rkey}", s.HandleEditActivityPage) 79 - r.Delete("/{rkey}", s.HandleDeleteActivity) 80 - }) 81 - 82 - r.Route("/stats", func(r chi.Router) { 83 - r.Use(middleware.AuthMiddleware(s.oauth)) 84 - r.Get("/", s.HandleStatsPage) 85 - r.Get("/time-per-graphs", s.HandleTimePerGraphs) 86 - }) 87 - 88 - r.Route("/session", func(r chi.Router) { 89 - r.Use(middleware.AuthMiddleware(s.oauth)) 90 - r.Post("/edit/{rkey}", s.HandleEditStudySessionPage) 91 - r.Get("/edit/{rkey}", s.HandleEditStudySessionPage) 92 - r.Get("/new", s.HandleNewStudySessionPage) 93 - r.Post("/new", s.HandleNewStudySessionPage) 94 - r.Delete("/{rkey}", s.HandleDeleteStudySession) 95 - 96 - r.Route("/reaction", func(r chi.Router) { 97 - r.Post("/", s.HandleReaction) 98 - r.Delete("/", s.HandleReaction) 99 - }) 100 - }) 101 - 102 - return r 103 - } 104 - 105 - func (s *State) UserRouter(mw *middleware.Middleware) http.Handler { 106 - r := chi.NewRouter() 107 - 108 - r.Use(middleware.StripLeadingAt) 109 - 110 - r.Group(func(r chi.Router) { 111 - r.Use(mw.ResolveIdent()) 112 - r.Route("/{user}", func(r chi.Router) { 113 - r.Get("/", s.HandleProfilePage) 114 - r.Get("/feed", s.HandleProfileFeed) 115 - }) 116 - }) 117 - 118 - r.NotFound(func(w http.ResponseWriter, r *http.Request) { 119 - w.WriteHeader(http.StatusNotFound) 120 - pages.NotFoundPage(pages.NotFoundPageParams{}).Render(r.Context(), w) 121 - }) 122 - 123 - return r 124 - } 125 - 126 - func (s *State) OAuthRouter() http.Handler { 127 - store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret)) 128 - oauth := oauthhandler.New(s.config, s.db, store, s.oauth, s.posthog) 129 - return oauth.Router() 130 - }
-197
internal/web/state/state.go
··· 1 - package state 2 - 3 - import ( 4 - "cmp" 5 - "context" 6 - "embed" 7 - "fmt" 8 - "io/fs" 9 - "log" 10 - "log/slog" 11 - "net/http" 12 - "slices" 13 - "strings" 14 - 15 - "github.com/posthog/posthog-go" 16 - 17 - "yoten.app/api/yoten" 18 - "yoten.app/internal/web" 19 - "yoten.app/internal/web/bsky" 20 - "yoten.app/internal/web/config" 21 - "yoten.app/internal/web/db" 22 - lexicon_ingester "yoten.app/internal/web/ingester" 23 - "yoten.app/internal/web/jetstream" 24 - "yoten.app/internal/web/oauth" 25 - "yoten.app/internal/web/pages" 26 - "yoten.app/internal/web/resolver" 27 - ) 28 - 29 - //go:embed static 30 - var Files embed.FS 31 - 32 - type ComputedData struct { 33 - SortedLanguages []db.Language 34 - SortedCategories []db.Category 35 - SortedResourceTypes []db.ResourceType 36 - SortedReactions []db.Reaction 37 - } 38 - 39 - type State struct { 40 - db *db.DB 41 - oauth *oauth.OAuth 42 - config *config.Config 43 - posthog posthog.Client 44 - idResolver *resolver.Resolver 45 - computedData ComputedData 46 - } 47 - 48 - func (s *State) Close() error { 49 - s.posthog.Close() 50 - 51 - return nil 52 - } 53 - 54 - func Make(ctx context.Context, config *config.Config) (*State, error) { 55 - d, err := db.Make(config.Core.DbPath) 56 - if err != nil { 57 - return nil, err 58 - } 59 - 60 - oauth := oauth.NewOAuth(d, config) 61 - 62 - idResolver := resolver.DefaultResolver() 63 - 64 - posthog, err := posthog.NewWithConfig(config.Posthog.ApiKey, posthog.Config{Endpoint: config.Posthog.Endpoint}) 65 - if err != nil { 66 - return nil, fmt.Errorf("failed to create posthog client: %w", err) 67 - } 68 - 69 - wrapper := db.DbWrapper{Execer: d} 70 - jc, err := jetstream.NewJetstreamClient( 71 - config.Jetstream.Endpoint, 72 - "yoten", 73 - []string{ 74 - yoten.ActorProfileNSID, 75 - yoten.FeedSessionNSID, 76 - yoten.FeedResourceNSID, 77 - yoten.FeedReactionNSID, 78 - yoten.ActivityDefNSID, 79 - yoten.GraphFollowNSID, 80 - }, 81 - nil, 82 - slog.Default(), 83 - wrapper, 84 - false, 85 - ) 86 - if err != nil { 87 - return nil, fmt.Errorf("failed to create jetstream client: %w", err) 88 - } 89 - 90 - ingester := lexicon_ingester.Ingester{ 91 - Db: wrapper, 92 - Config: config, 93 - } 94 - err = jc.StartJetstream(ctx, ingester.Ingest()) 95 - if err != nil { 96 - return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 97 - } 98 - 99 - sortedLanguages := make([]db.Language, 0, len(db.Languages)) 100 - for _, l := range db.Languages { 101 - sortedLanguages = append(sortedLanguages, l) 102 - } 103 - slices.SortFunc(sortedLanguages, func(a, b db.Language) int { 104 - return strings.Compare(a.Name, b.Name) 105 - }) 106 - 107 - sortedCategories := make([]db.Category, 0, len(db.Categories)) 108 - for _, c := range db.Categories { 109 - sortedCategories = append(sortedCategories, c) 110 - } 111 - slices.SortFunc(sortedCategories, func(a, b db.Category) int { 112 - return strings.Compare(a.Name, b.Name) 113 - }) 114 - 115 - sortedResourceTypes := make([]db.ResourceType, 0, len(db.ResourceTypeMap)) 116 - for _, value := range db.ResourceTypeMap { 117 - sortedResourceTypes = append(sortedResourceTypes, value) 118 - } 119 - slices.SortFunc(sortedResourceTypes, func(a, b db.ResourceType) int { 120 - return strings.Compare(a.String(), b.String()) 121 - }) 122 - 123 - sortedReactions := make([]db.Reaction, 0, len(db.Reactions)) 124 - for _, reaction := range db.Reactions { 125 - sortedReactions = append(sortedReactions, reaction) 126 - } 127 - slices.SortFunc(sortedReactions, func(a, b db.Reaction) int { 128 - return cmp.Compare(a.ID, b.ID) 129 - }) 130 - 131 - state := &State{ 132 - db: d, 133 - oauth: oauth, 134 - config: config, 135 - posthog: posthog, 136 - idResolver: idResolver, 137 - computedData: ComputedData{ 138 - SortedLanguages: sortedLanguages, 139 - SortedCategories: sortedCategories, 140 - SortedResourceTypes: sortedResourceTypes, 141 - SortedReactions: sortedReactions, 142 - }, 143 - } 144 - 145 - return state, nil 146 - } 147 - 148 - func (s *State) HandleIndexPage(w http.ResponseWriter, r *http.Request) { 149 - user, _ := s.getUserWithBskyProfile(r) 150 - pages.IndexPage(pages.IndexPageParams{ 151 - User: user, 152 - }).Render(r.Context(), w) 153 - } 154 - 155 - func (s *State) HandleStatic() http.Handler { 156 - if s.config.Core.Dev { 157 - return http.StripPrefix("/static/", http.FileServer(http.Dir("internal/web/state/static"))) 158 - } 159 - 160 - sub, err := fs.Sub(Files, "static") 161 - if err != nil { 162 - log.Fatal("failed to find static folder:", err) 163 - } 164 - return Cache(http.StripPrefix("/static/", http.FileServer(http.FS(sub)))) 165 - } 166 - 167 - func Cache(h http.Handler) http.Handler { 168 - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 169 - path := strings.Split(r.URL.Path, "?")[0] 170 - 171 - if strings.HasSuffix(path, ".js") { 172 - // Cache minified js for a year 173 - w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 174 - } else { 175 - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") 176 - } 177 - 178 - h.ServeHTTP(w, r) 179 - }) 180 - } 181 - 182 - func (s *State) getUserWithBskyProfile(r *http.Request) (*web.User, error) { 183 - oauth := s.oauth.GetUser(r) 184 - if oauth == nil { 185 - return nil, fmt.Errorf("failed to get oauth user") 186 - } 187 - 188 - bskyProfile, err := bsky.GetBskyProfile(oauth.Did) 189 - if err != nil { 190 - return nil, fmt.Errorf("failed to get bsky profile:, %w", err) 191 - } 192 - 193 - return &web.User{ 194 - OauthUser: *oauth, 195 - BskyProfile: bskyProfile, 196 - }, nil 197 - }
+19 -18
internal/web/state/stats.go internal/server/handlers/stats.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "log" 5 5 "net/http" 6 6 7 - "yoten.app/internal/web/db" 8 - "yoten.app/internal/web/htmx" 9 - "yoten.app/internal/web/pages" 10 - "yoten.app/internal/web/pages/partials" 7 + "yoten.app/internal/clients/bsky" 8 + "yoten.app/internal/db" 9 + "yoten.app/internal/server/htmx" 10 + "yoten.app/internal/server/views" 11 + "yoten.app/internal/server/views/partials" 11 12 ) 12 13 13 - func (s *State) HandleTimePerGraphs(w http.ResponseWriter, r *http.Request) { 14 - user, err := s.getUserWithBskyProfile(r) 14 + func (h *Handler) HandleTimePerGraphs(w http.ResponseWriter, r *http.Request) { 15 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 15 16 if err != nil { 16 17 log.Println("failed to get logged-in user:", err) 17 18 htmx.HxRedirect(w, "/login") ··· 21 22 periodStr := r.URL.Query().Get("period") 22 23 period := db.TimePeriod.FromString(0, periodStr) 23 24 24 - chartData, err := db.GetTimePerData(s.db, user.Did, period) 25 + chartData, err := db.GetTimePerData(h.Db, user.Did, period) 25 26 if err != nil { 26 27 log.Println("failed to get time per chart data:", err) 27 28 chartData = db.ChartsData{ ··· 36 37 }).Render(r.Context(), w) 37 38 } 38 39 39 - func (s *State) HandleStatsPage(w http.ResponseWriter, r *http.Request) { 40 - user, err := s.getUserWithBskyProfile(r) 40 + func (h *Handler) HandleStatsPage(w http.ResponseWriter, r *http.Request) { 41 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 41 42 if err != nil { 42 43 log.Println("failed to get logged-in user:", err) 43 44 htmx.HxRedirect(w, "/login") 44 45 return 45 46 } 46 47 47 - totalStudyTime, err := db.GetTotalStudyTime(s.db, user.Did) 48 + totalStudyTime, err := db.GetTotalStudyTime(h.Db, user.Did) 48 49 if err != nil { 49 50 log.Println("failed to get total study time:", err) 50 51 } 51 52 52 - totalStudySessions, err := db.GetTotalStudySessions(s.db, user.Did) 53 + totalStudySessions, err := db.GetTotalStudySessions(h.Db, user.Did) 53 54 if err != nil { 54 55 log.Println("failed to get total study study sessions:", err) 55 56 } 56 57 57 - totalActiveDays, err := db.GetTotalActiveDays(s.db, user.Did) 58 + totalActiveDays, err := db.GetTotalActiveDays(h.Db, user.Did) 58 59 if err != nil { 59 60 log.Println("failed to get total active days:", err) 60 61 } 61 62 62 - streak, err := db.GetCurrentStreak(s.db, user.Did) 63 + streak, err := db.GetCurrentStreak(h.Db, user.Did) 63 64 if err != nil { 64 65 log.Println("failed to get streak:", err) 65 66 } 66 67 67 - heatmap, err := db.GetHeatmapData(s.db, user.Did) 68 + heatmap, err := db.GetHeatmapData(h.Db, user.Did) 68 69 if err != nil { 69 70 log.Println("failed to get heatmap data:", err) 70 71 } 71 72 72 - inputOutputPercentage, err := db.GetInputOutputPercentage(s.db, user.Did) 73 + inputOutputPercentage, err := db.GetInputOutputPercentage(h.Db, user.Did) 73 74 if err != nil { 74 75 log.Println("failed to get input vs output data:", err) 75 76 } 76 77 77 - languageSummary, err := db.GetLanguageSummary(s.db, user.Did) 78 + languageSummary, err := db.GetLanguageSummary(h.Db, user.Did) 78 79 if err != nil { 79 80 log.Println("failed to get language time summary:", err) 80 81 } 81 82 languageChartSegments := db.ConvertToDonutChartSegments(languageSummary) 82 83 83 - pages.StatsPage(pages.StatsPageParams{ 84 + views.StatsPage(views.StatsPageParams{ 84 85 User: user, 85 86 TotalStudyTime: totalStudyTime, 86 87 TotalStudySessions: totalStudySessions,
+86 -54
internal/web/state/study-session.go internal/server/handlers/study-session.go
··· 1 - package state 1 + package handlers 2 2 3 3 import ( 4 4 "errors" ··· 14 14 "github.com/posthog/posthog-go" 15 15 16 16 "yoten.app/api/yoten" 17 - "yoten.app/internal/web" 18 - "yoten.app/internal/web/db" 19 - "yoten.app/internal/web/htmx" 20 - "yoten.app/internal/web/pages" 21 - "yoten.app/internal/web/pages/partials" 22 - ph "yoten.app/internal/web/posthog" 17 + "yoten.app/internal/atproto" 18 + "yoten.app/internal/clients/bsky" 19 + ph "yoten.app/internal/clients/posthog" 20 + "yoten.app/internal/db" 21 + "yoten.app/internal/server/htmx" 22 + "yoten.app/internal/server/views" 23 + "yoten.app/internal/server/views/partials" 24 + "yoten.app/internal/utils" 23 25 ) 24 26 25 27 const ( ··· 28 30 PendingStudySessionCreation string = "pending_study_session_creation" 29 31 ) 30 32 31 - func (s *State) parseStudySessionForm(r *http.Request) (db.StudySession, error) { 33 + func (h *Handler) parseStudySessionForm(r *http.Request) (db.StudySession, error) { 32 34 err := r.ParseForm() 33 35 if err != nil { 34 36 return db.StudySession{}, fmt.Errorf("invalid study session form: %w", err) ··· 79 81 if err != nil { 80 82 return db.StudySession{}, fmt.Errorf("failed to parse activity id: %w", err) 81 83 } 82 - activity, err := db.GetActivity(s.db, int(activityId)) 84 + activity, err := db.GetActivity(h.Db, int(activityId)) 83 85 if err != nil { 84 86 return db.StudySession{}, fmt.Errorf("failed to find activity '%d': %v", activityId, err) 85 87 } ··· 92 94 return db.StudySession{}, fmt.Errorf("failed to parse resource id: %w", err) 93 95 } 94 96 95 - r, err := db.GetResource(s.db, int(resourceId)) 97 + r, err := db.GetResource(h.Db, int(resourceId)) 96 98 if err != nil { 97 99 return db.StudySession{}, fmt.Errorf("failed to find resource '%d': %v", resourceId, err) 98 100 } ··· 112 114 return studySession, nil 113 115 } 114 116 115 - func (s *State) HandleStudySessionFeed(w http.ResponseWriter, r *http.Request) { 117 + func (h *Handler) HandleStudySessionFeed(w http.ResponseWriter, r *http.Request) { 116 118 isFriends, err := strconv.ParseBool(r.URL.Query().Get("friends")) 117 119 if err != nil { 118 120 log.Println("failed to parse friends value:", err) ··· 135 137 const pageSize = 10 136 138 offset := (page - 1) * pageSize 137 139 138 - user, fetchUserError := s.getUserWithBskyProfile(r) 140 + user, fetchUserError := bsky.GetUserWithBskyProfile(h.Oauth, r) 139 141 140 142 switch r.Method { 141 143 case http.MethodGet: 142 144 feed := []*db.StudySessionFeedItem{} 143 145 var err error 144 146 if !isFriends { 145 - feed, err = db.GetStudySessionFeed(s.db, pageSize+1, int(offset)) 147 + feed, err = db.GetStudySessionFeed(h.Db, pageSize+1, int(offset)) 146 148 if err != nil { 147 149 log.Println("failed to get global feed:", err) 150 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get global study session feed, try again later.") 151 + return 152 + } 153 + err = h.GetBskyProfileHydratedSessionFeed(feed) 154 + if err != nil { 155 + log.Println("failed to hydrate bsky profiles:", err) 148 156 htmx.HxError(w, http.StatusInternalServerError, "Failed to get global study session feed, try again later.") 149 157 return 150 158 } ··· 155 163 return 156 164 } 157 165 158 - feed, err = db.GetFriendsStudySessionFeed(s.db, user.Did, pageSize+1, int(offset)) 166 + feed, err = db.GetFriendsStudySessionFeed(h.Db, user.Did, pageSize+1, int(offset)) 159 167 if err != nil { 160 168 log.Println("failed to get global feed:", err) 161 169 htmx.HxError(w, http.StatusInternalServerError, "Failed to get global study session feed, try again later.") 162 170 return 163 171 } 172 + err = h.GetBskyProfileHydratedSessionFeed(feed) 173 + if err != nil { 174 + log.Println("failed to hydrate bsky profiles:", err) 175 + htmx.HxError(w, http.StatusInternalServerError, "Failed to get global study session feed, try again later.") 176 + return 177 + } 164 178 } 165 179 166 - feed, err = ApplyPendingChanges(s, w, r, feed, PendingStudySessionCreation, PendingStudySessionUpdates, PendingStudySessionDeletion) 180 + feed, err = ApplyPendingChanges(h, w, r, feed, PendingStudySessionCreation, PendingStudySessionUpdates, PendingStudySessionDeletion) 167 181 if err != nil { 168 182 log.Printf("failed to save yoten-session after processing pending changes: %v", err) 169 183 } ··· 183 197 } 184 198 } 185 199 186 - func (s *State) HandleEditStudySessionPage(w http.ResponseWriter, r *http.Request) { 187 - client, err := s.oauth.AuthorizedClient(r, w) 200 + func (h *Handler) HandleEditStudySessionPage(w http.ResponseWriter, r *http.Request) { 201 + client, err := h.Oauth.AuthorizedClient(r, w) 188 202 if err != nil { 189 203 log.Println("failed to get authorized client:", err) 190 204 htmx.HxRedirect(w, "/login") 191 205 return 192 206 } 193 207 194 - user, err := s.getUserWithBskyProfile(r) 208 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 195 209 if err != nil { 196 210 log.Println("failed to get logged-in user:", err) 197 211 htmx.HxRedirect(w, "/login") ··· 199 213 } 200 214 201 215 rkey := chi.URLParam(r, "rkey") 202 - studySession, err := db.GetStudySessionByRkey(s.db, user.Did, rkey) 216 + studySession, err := db.GetStudySessionByRkey(h.Db, user.Did, rkey) 203 217 if err != nil { 204 218 log.Println("failed to get study session from db:", err) 205 219 htmx.HxError(w, http.StatusInternalServerError, "Failed to update study session, try again later.") ··· 214 228 215 229 switch r.Method { 216 230 case http.MethodGet: 217 - userDefinedActivities, err := db.GetActivitiesByDid(s.db, user.Did) 231 + userDefinedActivities, err := db.GetActivitiesByDid(h.Db, user.Did) 218 232 if err != nil { 219 233 log.Println("failed to get user-defined activities:", err) 220 234 } 221 235 222 - resources, err := db.GetResourcesByDid(s.db, user.Did) 236 + resources, err := db.GetResourcesByDid(h.Db, user.Did) 223 237 if err != nil { 224 238 log.Println("failed to get user-defined resources:", err) 225 239 } 226 240 227 - preDefinedActivities, err := db.GetPredefinedActivities(s.db) 241 + preDefinedActivities, err := db.GetPredefinedActivities(h.Db) 228 242 if err != nil { 229 243 log.Println("failed to get pre-defined activities:", err) 230 244 } ··· 236 250 } 237 251 displayResources = append( 238 252 displayResources, 239 - web.Filter(resources, func(resource db.Resource) bool { 253 + utils.Filter(resources, func(resource db.Resource) bool { 240 254 if currentResource != nil { 241 255 return resource.Status != db.Deleted && resource.ID != currentResource.ID 242 256 } ··· 244 258 })..., 245 259 ) 246 260 247 - displayActivities := web.Filter(userDefinedActivities, func(activity db.Activity) bool { 261 + displayActivities := utils.Filter(userDefinedActivities, func(activity db.Activity) bool { 248 262 return activity.Status != db.Deleted 249 263 }) 250 264 displayActivities = append(displayActivities, preDefinedActivities...) 251 265 252 - languages, err := db.GetProfileLanguages(s.db, user.Did) 266 + languages, err := db.GetProfileLanguages(h.Db, user.Did) 253 267 if err != nil { 254 268 log.Println("failed to fetch profile languages:", err) 255 269 } 256 270 257 - pages.EditStudySessionPage(pages.EditStudySessionPageParams{ 271 + views.EditStudySessionPage(views.EditStudySessionPageParams{ 258 272 User: user, 259 273 StudySession: studySession, 260 274 Activities: displayActivities, ··· 262 276 SortedLanguages: languages, 263 277 }).Render(r.Context(), w) 264 278 case http.MethodPost: 265 - updatedStudySession, err := s.parseStudySessionForm(r) 279 + updatedStudySession, err := h.parseStudySessionForm(r) 266 280 if err != nil { 267 281 log.Println("invalid study session form:", err) 268 282 htmx.HxError(w, http.StatusBadRequest, "Failed to update study session, ensure all data is valid.") ··· 311 325 312 326 // Using user-defined activity 313 327 if updatedStudySession.Activity.Did != "" { 314 - stringCategories := web.Map(updatedStudySession.Activity.Categories, func(category db.Category) string { 328 + stringCategories := utils.Map(updatedStudySession.Activity.Categories, func(category db.Category) string { 315 329 return category.Name 316 330 }) 317 331 ··· 347 361 return 348 362 } 349 363 350 - err = SavePendingUpdate(s, w, r, PendingStudySessionUpdates, updatedStudySession) 364 + err = SavePendingUpdate(h, w, r, PendingStudySessionUpdates, updatedStudySession) 351 365 if err != nil { 352 366 log.Printf("failed to save yoten-session to add pending study session updates: %v", err) 353 367 } 354 368 355 - if !s.config.Core.Dev { 356 - err = s.posthog.Enqueue(posthog.Capture{ 369 + if !h.Config.Core.Dev { 370 + err = h.Posthog.Enqueue(posthog.Capture{ 357 371 DistinctId: user.Did, 358 372 Event: ph.StudySessionRecordEditedEvent, 359 373 Properties: posthog.NewProperties(). ··· 369 383 } 370 384 } 371 385 372 - func (s *State) HandleNewStudySessionPage(w http.ResponseWriter, r *http.Request) { 373 - user, err := s.getUserWithBskyProfile(r) 386 + func (h *Handler) HandleNewStudySessionPage(w http.ResponseWriter, r *http.Request) { 387 + user, err := bsky.GetUserWithBskyProfile(h.Oauth, r) 374 388 if err != nil { 375 389 log.Println("failed to get logged-in user:", err) 376 390 htmx.HxRedirect(w, "/login") 377 391 return 378 392 } 379 393 380 - client, err := s.oauth.AuthorizedClient(r, w) 394 + client, err := h.Oauth.AuthorizedClient(r, w) 381 395 if err != nil { 382 396 log.Println("failed to get authorized client:", err) 383 397 htmx.HxRedirect(w, "/login") ··· 386 400 387 401 switch r.Method { 388 402 case http.MethodGet: 389 - profile, err := db.GetProfile(s.db, user.Did) 403 + profile, err := db.GetProfile(h.Db, user.Did) 390 404 if err != nil { 391 405 log.Printf("failed to find %s in db: %s", user.Did, err) 392 406 htmx.HxError(w, http.StatusNotFound, "Failed to find user.") 393 407 return 394 408 } 395 409 396 - userDefinedActivities, err := db.GetActivitiesByDid(s.db, user.Did) 410 + userDefinedActivities, err := db.GetActivitiesByDid(h.Db, user.Did) 397 411 if err != nil { 398 412 log.Println("failed to get user-defined activities:", err) 399 413 } 400 - preDefinedActivities, err := db.GetPredefinedActivities(s.db) 414 + preDefinedActivities, err := db.GetPredefinedActivities(h.Db) 401 415 if err != nil { 402 416 log.Println("failed to get pre-defined activities:", err) 403 417 } 404 - activeActivities := web.Filter(userDefinedActivities, func(activity db.Activity) bool { 418 + activeActivities := utils.Filter(userDefinedActivities, func(activity db.Activity) bool { 405 419 return activity.Status != db.Deleted 406 420 }) 407 421 activeActivities = append(activeActivities, preDefinedActivities...) 408 422 409 - resources, err := db.GetResourcesByDid(s.db, user.Did) 423 + resources, err := db.GetResourcesByDid(h.Db, user.Did) 410 424 if err != nil { 411 425 log.Println("failed to get user-defined resources:", err) 412 426 } 413 - activeResources := web.Filter(resources, func(resource db.Resource) bool { 427 + activeResources := utils.Filter(resources, func(resource db.Resource) bool { 414 428 return resource.Status != db.Deleted 415 429 }) 416 430 417 - pages.NewStudySessionPage(pages.NewStudySessionPageParams{ 431 + views.NewStudySessionPage(views.NewStudySessionPageParams{ 418 432 User: user, 419 433 Profile: profile, 420 434 Activities: activeActivities, ··· 422 436 SortedLanguages: profile.Languages, 423 437 }).Render(r.Context(), w) 424 438 case http.MethodPost: 425 - newStudySession, err := s.parseStudySessionForm(r) 439 + newStudySession, err := h.parseStudySessionForm(r) 426 440 if err != nil { 427 441 log.Println("invalid study session form:", err) 428 442 htmx.HxError(w, http.StatusBadRequest, "Failed to update study session, ensure all data is valid.") 429 443 return 430 444 } 431 445 newStudySession.Did = user.Did 432 - newStudySession.Rkey = web.TID() 446 + newStudySession.Rkey = atproto.TID() 433 447 newStudySession.CreatedAt = time.Now() 434 448 435 449 if err := db.ValidateStudySession(newStudySession); err != nil { ··· 471 485 472 486 // Using user-defined activity 473 487 if newStudySession.Activity.Did != "" { 474 - stringCategories := web.Map(newStudySession.Activity.Categories, func(category db.Category) string { 488 + stringCategories := utils.Map(newStudySession.Activity.Categories, func(category db.Category) string { 475 489 return category.Name 476 490 }) 477 491 ··· 500 514 return 501 515 } 502 516 503 - err = SavePendingCreate(s, w, r, PendingStudySessionCreation, newStudySession) 517 + err = SavePendingCreate(h, w, r, PendingStudySessionCreation, newStudySession) 504 518 if err != nil { 505 519 log.Printf("failed to save yoten-session to add pending study session creation: %v", err) 506 520 } 507 521 508 - if !s.config.Core.Dev { 509 - err = s.posthog.Enqueue(posthog.Capture{ 522 + if !h.Config.Core.Dev { 523 + err = h.Posthog.Enqueue(posthog.Capture{ 510 524 DistinctId: user.Did, 511 525 Event: ph.StudySessionRecordCreatedEvent, 512 526 Properties: posthog.NewProperties(). ··· 527 541 } 528 542 } 529 543 530 - func (s *State) HandleDeleteStudySession(w http.ResponseWriter, r *http.Request) { 531 - user := s.oauth.GetUser(r) 544 + func (h *Handler) HandleDeleteStudySession(w http.ResponseWriter, r *http.Request) { 545 + user := h.Oauth.GetUser(r) 532 546 if user == nil { 533 547 log.Println("failed to get logged-in user") 534 548 htmx.HxRedirect(w, "/login") 535 549 return 536 550 } 537 551 538 - client, err := s.oauth.AuthorizedClient(r, w) 552 + client, err := h.Oauth.AuthorizedClient(r, w) 539 553 if err != nil { 540 554 log.Println("failed to get authorized client:", err) 541 555 htmx.HxError(w, http.StatusUnauthorized, "Failed to delete study session, try again later.") ··· 552 566 } 553 567 554 568 rkey := chi.URLParam(r, "rkey") 555 - studySession, err := db.GetStudySessionByRkey(s.db, user.Did, rkey) 569 + studySession, err := db.GetStudySessionByRkey(h.Db, user.Did, rkey) 556 570 if err != nil { 557 571 log.Println("failed to get study session from db:", err) 558 572 htmx.HxError(w, http.StatusInternalServerError, "Failed to delete study session, try again later.") ··· 576 590 return 577 591 } 578 592 579 - err = SavePendingDelete(s, w, r, PendingStudySessionDeletion, studySession) 593 + err = SavePendingDelete(h, w, r, PendingStudySessionDeletion, studySession) 580 594 if err != nil { 581 595 log.Printf("failed to save yoten-session to add pending study session deletion: %v", err) 582 596 } 583 597 584 - if !s.config.Core.Dev { 585 - err = s.posthog.Enqueue(posthog.Capture{ 598 + if !h.Config.Core.Dev { 599 + err = h.Posthog.Enqueue(posthog.Capture{ 586 600 DistinctId: user.Did, 587 601 Event: ph.StudySessionRecordDeletedEvent, 588 602 Properties: posthog.NewProperties(). ··· 598 612 htmx.HxRedirect(w, "/") 599 613 } 600 614 } 615 + 616 + func (h *Handler) GetBskyProfileHydratedSessionFeed(feed []*db.StudySessionFeedItem) error { 617 + profileDids := utils.Map(feed, func(item *db.StudySessionFeedItem) string { 618 + return item.Did 619 + }) 620 + bskyProfiles, err := bsky.GetBskyProfiles(profileDids) 621 + if err != nil { 622 + return err 623 + } 624 + 625 + for i := range feed { 626 + if profile, ok := bskyProfiles[feed[i].Did]; ok { 627 + feed[i].BskyProfile = profile 628 + } 629 + } 630 + 631 + return nil 632 + }
+1 -1
internal/web/tid.go internal/atproto/tid.go
··· 1 - package web 1 + package atproto 2 2 3 3 import ( 4 4 "github.com/bluesky-social/indigo/atproto/syntax"
-14
internal/web/types.go
··· 1 - package web 2 - 3 - import "yoten.app/internal/web/bsky" 4 - 5 - type OauthUser struct { 6 - Handle string 7 - Did string 8 - Pds string 9 - } 10 - 11 - type User struct { 12 - OauthUser 13 - BskyProfile bsky.BskyProfile 14 - }
+2 -2
internal/web/xrpcclient/xrpc.go internal/atproto/xrpc.go
··· 1 - package xrpcclient 1 + package atproto 2 2 3 3 import ( 4 4 "context" ··· 32 32 func (c *Client) RepoGetRecord(ctx context.Context, cid string, collection string, repo string, rkey string) (*atproto.RepoGetRecord_Output, error) { 33 33 var out atproto.RepoGetRecord_Output 34 34 35 - params := map[string]interface{}{ 35 + params := map[string]any{ 36 36 "cid": cid, 37 37 "collection": collection, 38 38 "repo": repo,
+1 -1
tailwindcss.sh
··· 1 1 #! /bin/bash 2 2 3 - tailwindcss -w -i ./input.css -o ./internal/web/state/static/style.css 3 + tailwindcss -w -i ./input.css -o ./static/style.css