Monorepo for Tangled tangled.org

appview: user/repos: introduce "all repos" page

authored by oppi.li and committed by Tangled fea0d049 87548bc0

Changed files
+252 -131
appview
+22 -9
appview/pages/pages.go
··· 311 311 312 312 type ProfilePageParams struct { 313 313 LoggedInUser *auth.User 314 - UserDid string 315 - UserHandle string 316 314 Repos []db.Repo 317 315 CollaboratingRepos []db.Repo 318 - ProfileStats ProfileStats 319 - FollowStatus db.FollowStatus 320 - Profile *db.Profile 321 - AvatarUri string 322 316 ProfileTimeline *db.ProfileTimeline 317 + Card ProfileCard 323 318 324 319 DidHandleMap map[string]string 325 320 } 326 321 327 - type ProfileStats struct { 328 - Followers int 329 - Following int 322 + type ProfileCard struct { 323 + UserDid string 324 + UserHandle string 325 + FollowStatus db.FollowStatus 326 + AvatarUri string 327 + Followers int 328 + Following int 329 + 330 + Profile *db.Profile 330 331 } 331 332 332 333 func (p *Pages) ProfilePage(w io.Writer, params ProfilePageParams) error { 333 334 return p.execute("user/profile", w, params) 335 + } 336 + 337 + type ReposPageParams struct { 338 + LoggedInUser *auth.User 339 + Repos []db.Repo 340 + Card ProfileCard 341 + 342 + DidHandleMap map[string]string 343 + } 344 + 345 + func (p *Pages) ReposPage(w io.Writer, params ReposPageParams) error { 346 + return p.execute("user/repos", w, params) 334 347 } 335 348 336 349 type FollowFragmentParams struct {
+1 -1
appview/pages/templates/user/fragments/editBio.html
··· 1 1 {{ define "user/fragments/editBio" }} 2 2 <form 3 - hx-post="/{{ .LoggedInUser.Did }}/profile/bio" 3 + hx-post="/profile/bio" 4 4 class="flex flex-col gap-4 my-2 max-w-full" 5 5 hx-disabled-elt="#save-btn,#cancel-btn" 6 6 hx-swap="none">
+1 -1
appview/pages/templates/user/fragments/editPins.html
··· 1 1 {{ define "user/fragments/editPins" }} 2 2 {{ $profile := .Profile }} 3 3 <form 4 - hx-post="/{{ .LoggedInUser.Did }}/profile/pins" 4 + hx-post="/profile/pins" 5 5 hx-disabled-elt="#save-btn,#cancel-btn" 6 6 hx-swap="none"> 7 7 <div class="flex items-center justify-between mb-2">
+98
appview/pages/templates/user/fragments/profileCard.html
··· 1 + {{ define "user/fragments/profileCard" }} 2 + <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 3 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 4 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 5 + {{ if .AvatarUri }} 6 + <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 7 + {{ end }} 8 + </div> 9 + <div class="col-span-2"> 10 + <p title="{{ didOrHandle .UserDid .UserHandle }}" 11 + class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 12 + {{ didOrHandle .UserDid .UserHandle }} 13 + </p> 14 + 15 + <div class="md:hidden"> 16 + {{ block "followerFollowing" (list .Followers .Following) }} {{ end }} 17 + </div> 18 + </div> 19 + <div class="col-span-3 md:col-span-full"> 20 + <div id="profile-bio" class="text-sm"> 21 + {{ $profile := .Profile }} 22 + {{ with .Profile }} 23 + 24 + {{ if .Description }} 25 + <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 26 + {{ end }} 27 + 28 + <div class="hidden md:block"> 29 + {{ block "followerFollowing" (list $.Followers $.Following) }} {{ end }} 30 + </div> 31 + 32 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 33 + {{ if .Location }} 34 + <div class="flex items-center gap-2"> 35 + <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 36 + <span>{{ .Location }}</span> 37 + </div> 38 + {{ end }} 39 + {{ if .IncludeBluesky }} 40 + <div class="flex items-center gap-2"> 41 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 42 + <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}"> 43 + bluesky/{{ didOrHandle $.UserDid $.UserHandle }} 44 + </a> 45 + </div> 46 + {{ end }} 47 + {{ range $link := .Links }} 48 + {{ if $link }} 49 + <div class="flex items-center gap-2"> 50 + <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 51 + <a href="{{ $link }}">{{ $link }}</a> 52 + </div> 53 + {{ end }} 54 + {{ end }} 55 + {{ if not $profile.IsStatsEmpty }} 56 + <div class="flex items-center justify-evenly gap-2 py-2"> 57 + {{ range $stat := .Stats }} 58 + {{ if $stat.Kind }} 59 + <div class="flex flex-col items-center gap-2"> 60 + <span class="text-xl font-bold">{{ $stat.Value }}</span> 61 + <span>{{ $stat.Kind.String }}</span> 62 + </div> 63 + {{ end }} 64 + {{ end }} 65 + </div> 66 + {{ end }} 67 + </div> 68 + {{ end }} 69 + {{ if ne .FollowStatus.String "IsSelf" }} 70 + {{ template "user/fragments/follow" . }} 71 + {{ else }} 72 + <button id="editBtn" 73 + class="btn mt-2 w-full flex items-center gap-2" 74 + hx-target="#profile-bio" 75 + hx-get="/profile/edit-bio" 76 + hx-swap="innerHTML"> 77 + {{ i "pencil" "w-4 h-4" }} 78 + edit 79 + </button> 80 + {{ end }} 81 + </div> 82 + <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 83 + </div> 84 + </div> 85 + </div> 86 + {{ end }} 87 + 88 + {{ define "followerFollowing" }} 89 + {{ $followers := index . 0 }} 90 + {{ $following := index . 1 }} 91 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 92 + <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 93 + <span id="followers">{{ $followers }} followers</span> 94 + <span class="select-none after:content-['·']"></span> 95 + <span id="following">{{ $following }} following</span> 96 + </div> 97 + {{ end }} 98 +
+12 -102
appview/pages/templates/user/profile.html
··· 1 - {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }}{{ end }} 2 2 3 3 {{ define "content" }} 4 4 <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 5 <div class="md:col-span-2 order-1 md:order-1"> 6 - {{ block "profileCard" . }}{{ end }} 6 + {{ template "user/fragments/profileCard" .Card }} 7 7 </div> 8 8 <div id="all-repos" class="md:col-span-3 order-2 md:order-2"> 9 9 {{ block "ownRepos" . }}{{ end }} ··· 225 225 {{ end }} 226 226 {{ end }} 227 227 228 - {{ define "profileCard" }} 229 - <div class="bg-white dark:bg-gray-800 px-6 py-4 rounded drop-shadow-sm max-h-fit"> 230 - <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 231 - <div id="avatar" class="col-span-1 flex justify-center items-center"> 232 - {{ if .AvatarUri }} 233 - <img class="w-3/4 rounded-full p-2" src="{{ .AvatarUri }}" /> 234 - {{ end }} 235 - </div> 236 - <div class="col-span-2"> 237 - <p title="{{ didOrHandle .UserDid .UserHandle }}" 238 - class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 239 - {{ didOrHandle .UserDid .UserHandle }} 240 - </p> 241 - 242 - <div class="md:hidden"> 243 - {{ block "followerFollowing" .ProfileStats }} {{ end }} 244 - </div> 245 - </div> 246 - <div class="col-span-3 md:col-span-full"> 247 - <div id="profile-bio" class="text-sm"> 248 - {{ $profile := .Profile }} 249 - {{ with .Profile }} 250 - 251 - {{ if .Description }} 252 - <p class="text-base pb-4 md:pb-2">{{ .Description }}</p> 253 - {{ end }} 254 - 255 - <div class="hidden md:block"> 256 - {{ block "followerFollowing" $.ProfileStats }} {{ end }} 257 - </div> 258 - 259 - <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 260 - {{ if .Location }} 261 - <div class="flex items-center gap-2"> 262 - <span class="flex-shrink-0">{{ i "map-pin" "size-4" }}</span> 263 - <span>{{ .Location }}</span> 264 - </div> 265 - {{ end }} 266 - {{ if .IncludeBluesky }} 267 - <div class="flex items-center gap-2"> 268 - <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 269 - <a id="bluesky-link" href="https://bsky.app/profile/{{ $.UserDid }}"> 270 - bluesky/{{ didOrHandle $.UserDid $.UserHandle }} 271 - </a> 272 - </div> 273 - {{ end }} 274 - {{ range $link := .Links }} 275 - {{ if $link }} 276 - <div class="flex items-center gap-2"> 277 - <span class="flex-shrink-0">{{ i "link" "size-4" }}</span> 278 - <a href="{{ $link }}">{{ $link }}</a> 279 - </div> 280 - {{ end }} 281 - {{ end }} 282 - {{ if not $profile.IsStatsEmpty }} 283 - <div class="flex items-center justify-evenly gap-2 py-2"> 284 - {{ range $stat := .Stats }} 285 - {{ if $stat.Kind }} 286 - <div class="flex flex-col items-center gap-2"> 287 - <span class="text-xl font-bold">{{ $stat.Value }}</span> 288 - <span>{{ $stat.Kind.String }}</span> 289 - </div> 290 - {{ end }} 291 - {{ end }} 292 - </div> 293 - {{ end }} 294 - </div> 295 - {{ end }} 296 - {{ if ne .FollowStatus.String "IsSelf" }} 297 - {{ template "user/fragments/follow" . }} 298 - {{ else }} 299 - <button id="editBtn" 300 - class="btn mt-2 w-full flex items-center gap-2" 301 - hx-target="#profile-bio" 302 - hx-get="/{{ $.UserDid }}/profile/edit-bio" 303 - hx-swap="innerHTML"> 304 - {{ i "pencil" "w-4 h-4" }} 305 - edit 306 - </button> 307 - {{ end }} 308 - </div> 309 - <div id="update-profile" class="text-red-400 dark:text-red-500"></div> 310 - </div> 311 - </div> 312 - </div> 313 - {{ end }} 314 - 315 - {{ define "followerFollowing" }} 316 - <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 317 - <span class="flex-shrink-0">{{ i "users" "size-4" }}</span> 318 - <span id="followers">{{ .Followers }} followers</span> 319 - <span class="select-none after:content-['·']"></span> 320 - <span id="following">{{ .Following }} following</span> 321 - </div> 322 - {{ end }} 323 - 324 228 {{ define "ownRepos" }} 325 229 <div class="text-sm font-bold p-2 pr-0 dark:text-white flex items-center justify-between gap-2"> 326 - <span>PINNED REPOS</span> 327 - {{ if and .LoggedInUser (eq .LoggedInUser.Did .UserDid) }} 328 - <button hx-get="/{{ $.UserDid }}/profile/edit-pins" hx-target="#all-repos" class="btn font-normal text-sm flex gap-2 items-center"> 230 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}?tab=repos" 231 + class="flex text-black dark:text-white items-center gap-4 no-underline hover:no-underline group"> 232 + <span>PINNED REPOS</span> 233 + <span class="flex md:hidden group-hover:flex gap-2 items-center font-normal text-sm text-gray-500 dark:text-gray-400 "> 234 + view all {{ i "chevron-right" "w-4 h-4" }} 235 + </span> 236 + </a> 237 + {{ if and .LoggedInUser (eq .LoggedInUser.Did .Card.UserDid) }} 238 + <button hx-get="profile/edit-pins" hx-target="#all-repos" class="btn font-normal text-sm flex gap-2 items-center"> 329 239 {{ i "pencil" "w-3 h-3" }} 330 240 edit 331 241 </button> ··· 337 247 id="repo-card" 338 248 class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 339 249 <div id="repo-card-name" class="font-medium"> 340 - <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 250 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 341 251 >{{ .Name }}</a 342 252 > 343 253 </div>
+44
appview/pages/templates/user/repos.html
··· 1 + {{ define "title" }}{{ or .Card.UserHandle .Card.UserDid }} · repos {{ end }} 2 + 3 + {{ define "content" }} 4 + <div class="grid grid-cols-1 md:grid-cols-8 gap-6"> 5 + <div class="md:col-span-2 order-1 md:order-1"> 6 + {{ template "user/fragments/profileCard" .Card }} 7 + </div> 8 + <div id="all-repos" class="md:col-span-6 order-2 md:order-2"> 9 + {{ block "ownRepos" . }}{{ end }} 10 + </div> 11 + </div> 12 + {{ end }} 13 + 14 + {{ define "ownRepos" }} 15 + <p class="text-sm font-bold p-2 dark:text-white">ALL REPOSITORIES</p> 16 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 17 + {{ range .Repos }} 18 + <div 19 + id="repo-card" 20 + class="py-4 px-6 drop-shadow-sm rounded bg-white dark:bg-gray-800"> 21 + <div id="repo-card-name" class="font-medium"> 22 + <a href="/@{{ or $.Card.UserHandle $.Card.UserDid }}/{{ .Name }}" 23 + >{{ .Name }}</a 24 + > 25 + </div> 26 + {{ if .Description }} 27 + <div class="text-gray-600 dark:text-gray-300 text-sm"> 28 + {{ .Description }} 29 + </div> 30 + {{ end }} 31 + <div class="text-gray-400 pt-1 text-sm font-mono inline-flex gap-4 mt-auto"> 32 + {{ if .RepoStats.StarCount }} 33 + <div class="flex gap-1 items-center text-sm"> 34 + {{ i "star" "w-3 h-3 fill-current" }} 35 + <span>{{ .RepoStats.StarCount }}</span> 36 + </div> 37 + {{ end }} 38 + </div> 39 + </div> 40 + {{ else }} 41 + <p class="px-6 dark:text-white">This user does not have any repos yet.</p> 42 + {{ end }} 43 + </div> 44 + {{ end }}
+65 -10
appview/state/profile.go
··· 20 20 "tangled.sh/tangled.sh/core/appview/pages" 21 21 ) 22 22 23 - func (s *State) ProfilePage(w http.ResponseWriter, r *http.Request) { 23 + func (s *State) Profile(w http.ResponseWriter, r *http.Request) { 24 + tabVal := r.URL.Query().Get("tab") 25 + switch tabVal { 26 + case "": 27 + s.profilePage(w, r) 28 + case "repos": 29 + s.reposPage(w, r) 30 + } 31 + } 32 + 33 + func (s *State) profilePage(w http.ResponseWriter, r *http.Request) { 24 34 didOrHandle := chi.URLParam(r, "user") 25 35 if didOrHandle == "" { 26 36 http.Error(w, "Bad request", http.StatusBadRequest) ··· 118 128 profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 119 129 s.pages.ProfilePage(w, pages.ProfilePageParams{ 120 130 LoggedInUser: loggedInUser, 121 - UserDid: ident.DID.String(), 122 - UserHandle: ident.Handle.String(), 123 131 Repos: pinnedRepos, 124 132 CollaboratingRepos: pinnedCollaboratingRepos, 125 - ProfileStats: pages.ProfileStats{ 126 - Followers: followers, 127 - Following: following, 133 + DidHandleMap: didHandleMap, 134 + Card: pages.ProfileCard{ 135 + UserDid: ident.DID.String(), 136 + UserHandle: ident.Handle.String(), 137 + AvatarUri: profileAvatarUri, 138 + Profile: profile, 139 + FollowStatus: followStatus, 140 + Followers: followers, 141 + Following: following, 128 142 }, 129 - Profile: profile, 130 - FollowStatus: db.FollowStatus(followStatus), 131 - DidHandleMap: didHandleMap, 132 - AvatarUri: profileAvatarUri, 133 143 ProfileTimeline: timeline, 144 + }) 145 + } 146 + 147 + func (s *State) reposPage(w http.ResponseWriter, r *http.Request) { 148 + ident, ok := r.Context().Value("resolvedId").(identity.Identity) 149 + if !ok { 150 + s.pages.Error404(w) 151 + return 152 + } 153 + 154 + profile, err := db.GetProfile(s.db, ident.DID.String()) 155 + if err != nil { 156 + log.Printf("getting profile data for %s: %s", ident.DID.String(), err) 157 + } 158 + 159 + repos, err := db.GetAllReposByDid(s.db, ident.DID.String()) 160 + if err != nil { 161 + log.Printf("getting repos for %s: %s", ident.DID.String(), err) 162 + } 163 + 164 + loggedInUser := s.auth.GetUser(r) 165 + followStatus := db.IsNotFollowing 166 + if loggedInUser != nil { 167 + followStatus = db.GetFollowStatus(s.db, loggedInUser.Did, ident.DID.String()) 168 + } 169 + 170 + followers, following, err := db.GetFollowerFollowing(s.db, ident.DID.String()) 171 + if err != nil { 172 + log.Printf("getting follow stats repos for %s: %s", ident.DID.String(), err) 173 + } 174 + 175 + profileAvatarUri := s.GetAvatarUri(ident.Handle.String()) 176 + 177 + s.pages.ReposPage(w, pages.ReposPageParams{ 178 + LoggedInUser: loggedInUser, 179 + Repos: repos, 180 + Card: pages.ProfileCard{ 181 + UserDid: ident.DID.String(), 182 + UserHandle: ident.Handle.String(), 183 + AvatarUri: profileAvatarUri, 184 + Profile: profile, 185 + FollowStatus: followStatus, 186 + Followers: followers, 187 + Following: following, 188 + }, 134 189 }) 135 190 } 136 191
+9 -8
appview/state/router.go
··· 53 53 r.Use(StripLeadingAt) 54 54 55 55 r.With(ResolveIdent(s)).Route("/{user}", func(r chi.Router) { 56 - r.Get("/", s.ProfilePage) 57 - r.Route("/profile", func(r chi.Router) { 58 - r.Use(middleware.AuthMiddleware(s.auth)) 59 - r.Get("/edit-bio", s.EditBioFragment) 60 - r.Get("/edit-pins", s.EditPinsFragment) 61 - r.Post("/bio", s.UpdateProfileBio) 62 - r.Post("/pins", s.UpdateProfilePins) 63 - }) 56 + r.Get("/", s.Profile) 64 57 65 58 r.With(ResolveRepo(s)).Route("/{repo}", func(r chi.Router) { 66 59 r.Get("/", s.RepoIndex) ··· 244 237 r.With(middleware.AuthMiddleware(s.auth)).Route("/star", func(r chi.Router) { 245 238 r.Post("/", s.Star) 246 239 r.Delete("/", s.Star) 240 + }) 241 + 242 + r.Route("/profile", func(r chi.Router) { 243 + r.Use(middleware.AuthMiddleware(s.auth)) 244 + r.Get("/edit-bio", s.EditBioFragment) 245 + r.Get("/edit-pins", s.EditPinsFragment) 246 + r.Post("/bio", s.UpdateProfileBio) 247 + r.Post("/pins", s.UpdateProfilePins) 247 248 }) 248 249 249 250 r.Mount("/settings", s.SettingsRouter())