Monorepo for Tangled tangled.org

better profile view; fix logout button

fix issue closing, indicate alpha etc

Changed files
+155 -68
appview
pages
templates
layouts
user
state
+1
appview/pages/pages.go
··· 146 146 ProfileStats ProfileStats 147 147 FollowStatus db.FollowStatus 148 148 DidHandleMap map[string]string 149 + AvatarUri string 149 150 } 150 151 151 152 type ProfileStats struct {
+2 -2
appview/pages/templates/layouts/topbar.html
··· 3 3 <div class="container flex justify-between p-0"> 4 4 <div id="left-items"> 5 5 <a href="/" hx-boost="true" class="flex gap-2 font-semibold italic"> 6 - tangled 6 + tangled<sub>alpha</sub> 7 7 </a> 8 8 </div> 9 9 <div id="right-items" class="flex gap-2"> 10 10 {{ with .LoggedInUser }} 11 - <a href="/repo/new"hx-boost="true"> 11 + <a href="/repo/new" hx-boost="true"> 12 12 <i class="w-6 h-6" data-lucide="plus"></i> 13 13 </a> 14 14 {{ block "dropDown" . }} {{ end }}
+85 -61
appview/pages/templates/user/profile.html
··· 1 1 {{ define "title" }}{{ or .UserHandle .UserDid }}{{ end }} 2 2 3 3 {{ define "content" }} 4 + <div class="grid grid-cols-1 lg:grid-cols-4 gap-6"> 5 + <div class="lg:col-span-1"> 6 + {{ block "profileCard" . }} {{ end }} 7 + </div> 4 8 5 - <div class="flex"> 6 - <h1 class="pb-1 px-6"> 7 - {{ didOrHandle .UserDid .UserHandle }} 8 - </h1> 9 - {{ if ne .FollowStatus.String "IsSelf" }} 10 - <button id="followBtn" 11 - class="btn mt-2" 12 - {{ if eq .FollowStatus.String "IsNotFollowing" }} 13 - hx-post="/follow?subject={{.UserDid}}" 14 - {{ else }} 15 - hx-delete="/follow?subject={{.UserDid}}" 16 - {{ end }} 17 - hx-trigger="click" 18 - hx-target="#followBtn" 19 - hx-swap="outerHTML" 20 - > 21 - {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 22 - </button> 23 - {{ end }} 9 + <div class="lg:col-span-3"> 10 + {{ block "ownRepos" . }} {{ end }} 11 + {{ block "collaboratingRepos" . }} {{ end }} 12 + </div> 13 + </div> 14 + {{ end }} 15 + 16 + {{ define "profileCard" }} 17 + <div class="bg-white px-6 py-4 rounded drop-shadow-sm max-h-fit"> 18 + <div class="flex justify-center items-center"> 19 + {{ if .AvatarUri }} 20 + <img class="w-1/2 lg:w-full rounded-full p-2" src="{{ .AvatarUri }}" /> 21 + {{ end }} 24 22 </div> 25 - <div class="text-sm mb-4 px-6"> 23 + <p class="text-xl font-bold text-center"> 24 + {{ didOrHandle .UserDid .UserHandle }} 25 + </p> 26 + <div class="text-sm text-center"> 26 27 <span>{{ .ProfileStats.Followers }} followers</span> 27 28 <div class="inline-block px-1 select-none after:content-['·']"></div> 28 29 <span>{{ .ProfileStats.Following }} following</span> 29 30 </div> 30 - <p class="text-sm font-bold py-2 px-6">REPOS</p> 31 - <div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 32 - {{ range .Repos }} 33 - <div 34 - id="repo-card" 35 - class="py-4 px-6 drop-shadow-sm rounded bg-white" 36 - > 37 - <div id="repo-card-name" class="font-medium"> 38 - <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 39 - >{{ .Name }}</a 31 + 32 + {{ if ne .FollowStatus.String "IsSelf" }} 33 + <button id="followBtn" 34 + class="btn mt-2 w-full" 35 + {{ if eq .FollowStatus.String "IsNotFollowing" }} 36 + hx-post="/follow?subject={{.UserDid}}" 37 + {{ else }} 38 + hx-delete="/follow?subject={{.UserDid}}" 39 + {{ end }} 40 + hx-trigger="click" 41 + hx-target="#followBtn" 42 + hx-swap="outerHTML" 40 43 > 41 - </div> 42 - <div 43 - id="repo-knot-name" 44 - class="text-gray-600 text-sm font-mono" 45 - > 46 - {{ .Knot }} 47 - </div> 48 - </div> 49 - {{ else }} 50 - <p class="px-6">This user does not have any repos yet.</p> 51 - {{ end }} 44 + {{ if eq .FollowStatus.String "IsNotFollowing" }}Follow{{ else }}Unfollow{{ end }} 45 + </button> 46 + {{ end }} 47 + </div> 48 + {{ end }} 49 + 50 + {{ define "ownRepos" }} 51 + <p class="text-sm font-bold py-2 px-6">REPOS</p> 52 + <div id="repos" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 53 + {{ range .Repos }} 54 + <div 55 + id="repo-card" 56 + class="py-4 px-6 drop-shadow-sm rounded bg-white" 57 + > 58 + <div id="repo-card-name" class="font-medium"> 59 + <a href="/@{{ or $.UserHandle $.UserDid }}/{{ .Name }}" 60 + >{{ .Name }}</a 61 + > 62 + </div> 63 + <div 64 + id="repo-knot-name" 65 + class="text-gray-600 text-sm font-mono" 66 + > 67 + {{ .Knot }} 68 + </div> 52 69 </div> 53 - <p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p> 54 - <div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 55 - {{ range .CollaboratingRepos }} 56 - <div 57 - id="repo-card" 58 - class="py-4 px-6 drop-shadow-sm rounded bg-white" 59 - > 60 - <div id="repo-card-name" class="font-medium"> 61 - <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 62 - {{ index $.DidHandleMap .Did }}/{{ .Name }} 63 - </a> 64 - </div> 65 - <div 66 - id="repo-knot-name" 67 - class="text-gray-600 text-sm font-mono" 68 - > 69 - {{ .Knot }} 70 - </div> 71 - </div> 72 70 {{ else }} 73 - <p class="px-6">This user is not collaborating.</p> 71 + <p class="px-6">This user does not have any repos yet.</p> 74 72 {{ end }} 73 + </div> 74 + {{ end }} 75 + 76 + {{ define "collaboratingRepos" }} 77 + <p class="text-sm font-bold py-2 px-6">COLLABORATING ON</p> 78 + <div id="collaborating" class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6"> 79 + {{ range .CollaboratingRepos }} 80 + <div 81 + id="repo-card" 82 + class="py-4 px-6 drop-shadow-sm rounded bg-white" 83 + > 84 + <div id="repo-card-name" class="font-medium"> 85 + <a href="/{{ index $.DidHandleMap .Did }}/{{ .Name }}"> 86 + {{ index $.DidHandleMap .Did }}/{{ .Name }} 87 + </a> 88 + </div> 89 + <div 90 + id="repo-knot-name" 91 + class="text-gray-600 text-sm font-mono" 92 + > 93 + {{ .Knot }} 94 + </div> 75 95 </div> 96 + {{ else }} 97 + <p class="px-6">This user is not collaborating.</p> 98 + {{ end }} 99 + </div> 76 100 {{ end }}
+2 -2
appview/state/follow.go
··· 61 61 62 62 w.Write([]byte(fmt.Sprintf(` 63 63 <button id="followBtn" 64 - class="btn mt-2" 64 + class="btn mt-2 w-full" 65 65 hx-delete="/follow?subject=%s" 66 66 hx-trigger="click" 67 67 hx-target="#followBtn" ··· 98 98 99 99 w.Write([]byte(fmt.Sprintf(` 100 100 <button id="followBtn" 101 - class="btn mt-2" 101 + class="btn mt-2 w-full" 102 102 hx-post="/follow?subject=%s" 103 103 hx-trigger="click" 104 104 hx-target="#followBtn"
+12 -2
appview/state/repo.go
··· 9 9 "math/rand/v2" 10 10 "net/http" 11 11 "path" 12 + "slices" 12 13 "strconv" 13 14 "strings" 14 15 "time" ··· 611 612 return 612 613 } 613 614 615 + collaborators, err := f.Collaborators(r.Context(), s) 616 + if err != nil { 617 + log.Println("failed to fetch repo collaborators: %w", err) 618 + } 619 + isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool { 620 + return user.Did == collab.Did 621 + }) 622 + isIssueOwner := user.Did == issue.OwnerDid 623 + 614 624 // TODO: make this more granular 615 - if user.Did == f.OwnerDid() { 625 + if isIssueOwner || isCollaborator { 616 626 617 627 closed := tangled.RepoIssueStateClosed 618 628 ··· 645 655 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt)) 646 656 return 647 657 } else { 648 - log.Println("user is not the owner of the repo") 658 + log.Println("user is not permitted to close issue") 649 659 http.Error(w, "for biden", http.StatusUnauthorized) 650 660 return 651 661 }
+53 -1
appview/state/state.go
··· 5 5 "crypto/hmac" 6 6 "crypto/sha256" 7 7 "encoding/hex" 8 + "encoding/json" 8 9 "fmt" 9 10 "log" 10 11 "log/slog" ··· 128 129 129 130 func (s *State) Logout(w http.ResponseWriter, r *http.Request) { 130 131 s.auth.ClearSession(r, w) 131 - s.pages.HxRedirect(w, "/") 132 + http.Redirect(w, r, "/login", http.StatusTemporaryRedirect) 132 133 } 133 134 134 135 func (s *State) Timeline(w http.ResponseWriter, r *http.Request) { ··· 648 649 followStatus = s.db.GetFollowStatus(loggedInUser.Did, ident.DID.String()) 649 650 } 650 651 652 + profileAvatarUri, err := GetAvatarUri(ident.DID.String()) 653 + if err != nil { 654 + log.Println("failed to fetch bsky avatar", err) 655 + } 656 + 651 657 s.pages.ProfilePage(w, pages.ProfilePageParams{ 652 658 LoggedInUser: loggedInUser, 653 659 UserDid: ident.DID.String(), ··· 660 666 }, 661 667 FollowStatus: db.FollowStatus(followStatus), 662 668 DidHandleMap: didHandleMap, 669 + AvatarUri: profileAvatarUri, 663 670 }) 671 + } 672 + 673 + func GetAvatarUri(did string) (string, error) { 674 + recordURL := fmt.Sprintf("https://bsky.social/xrpc/com.atproto.repo.getRecord?repo=%s&collection=app.bsky.actor.profile&rkey=self", did) 675 + 676 + recordResp, err := http.Get(recordURL) 677 + if err != nil { 678 + return "", err 679 + } 680 + defer recordResp.Body.Close() 681 + 682 + if recordResp.StatusCode != http.StatusOK { 683 + return "", fmt.Errorf("getRecord API returned status code %d", recordResp.StatusCode) 684 + } 685 + 686 + var profileResp map[string]any 687 + if err := json.NewDecoder(recordResp.Body).Decode(&profileResp); err != nil { 688 + return "", err 689 + } 690 + 691 + value, ok := profileResp["value"].(map[string]any) 692 + if !ok { 693 + log.Println(profileResp) 694 + return "", fmt.Errorf("no value found for handle %s", did) 695 + } 696 + 697 + avatar, ok := value["avatar"].(map[string]any) 698 + if !ok { 699 + log.Println(profileResp) 700 + return "", fmt.Errorf("no avatar found for handle %s", did) 701 + } 702 + 703 + blobRef, ok := avatar["ref"].(map[string]any) 704 + if !ok { 705 + log.Println(profileResp) 706 + return "", fmt.Errorf("no ref found for handle %s", did) 707 + } 708 + 709 + link, ok := blobRef["$link"].(string) 710 + if !ok { 711 + log.Println(profileResp) 712 + return "", fmt.Errorf("no link found for handle %s", did) 713 + } 714 + 715 + return fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s", did, link), nil 664 716 } 665 717 666 718 func (s *State) Router() http.Handler {