+1
appview/pages/pages.go
+1
appview/pages/pages.go
+2
-2
appview/pages/templates/layouts/topbar.html
+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
+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
+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
+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
+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 {