nandi/gleanpublic⑂ Fork 0
⑂ 5641d4d
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Add support for fetching user profile display name and avatarUnverified

Julien Robert committed 2026-04-20T12:45:08+02:00 Browse files
5641d4d parent: c613ecc
added .env.example +6 -0
new file mode 100644
@@ -0,0 +1,6 @@
1+GLEAN_ADDR=:8080
2+GLEAN_DB=glean.db
3+GLEAN_RELAY=wss://bsky.network
4+# Leave empty for localhost OAuth (development)
5+# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
6+# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback
new file mode 100644
@@ -0,0 +1,6 @@
1+GLEAN_ADDR=:8080
2+GLEAN_DB=glean.db
3+GLEAN_RELAY=wss://bsky.network
4+# Leave empty for localhost OAuth (development)
5+# GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata
6+# GLEAN_OAUTH_REDIRECT_URL=https://glean.at/auth/callback
added Makefile +13 -0
new file mode 100644
@@ -0,0 +1,13 @@
1+.PHONY: dev run build test clean
2+
3+dev:
4+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run .
5+
6+build:
7+ go build -o glean .
8+
9+test:
10+ go test ./...
11+
12+clean:
13+ rm -f glean glean.db
new file mode 100644
@@ -0,0 +1,13 @@
1+.PHONY: dev run build test clean
2+
3+dev:
4+ @if [ -f .env ]; then set -a; . ./.env; set +a; fi && go run .
5+
6+build:
7+ go build -o glean .
8+
9+test:
10+ go test ./...
11+
12+clean:
13+ rm -f glean glean.db
modified internal/db/social.go +17 -15
@@ -8,17 +8,18 @@ import (
88 )
99
1010 type Annotation struct {
11- ID int64
12- URI string
13- AuthorDID string
14- FeedURL string
15- ArticleURL string
16- Quote sql.NullString
17- Note sql.NullString
18- Tags sql.NullString
19- Rating sql.NullInt64
20- CreatedAt sql.NullTime
21- CID sql.NullString
11+ ID int64
12+ URI string
13+ AuthorDID string
14+ AuthorHandle string
15+ FeedURL string
16+ ArticleURL string
17+ Quote sql.NullString
18+ Note sql.NullString
19+ Tags sql.NullString
20+ Rating sql.NullInt64
21+ CreatedAt sql.NullTime
22+ CID sql.NullString
2223 }
2324
2425 type Like struct {
@@ -61,12 +62,13 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
6162 args = append(args, authorDID)
6263 }
6364
64- query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid
65- FROM annotations`
65+ query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
66+ FROM annotations a
67+ LEFT JOIN users u ON a.author_did = u.did`
6668 if len(conds) > 0 {
6769 query += ` WHERE ` + strings.Join(conds, " AND ")
6870 }
69- query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)
71+ query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset)
7072
7173 rows, err := db.QueryContext(ctx, query, args...)
7274 if err != nil {
@@ -77,7 +79,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
7779 var annotations []*Annotation
7880 for rows.Next() {
7981 a := &Annotation{}
80- if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL,
82+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,
8183 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
8284 return nil, err
8385 }
@@ -8,17 +8,18 @@ import (
8 )8 )
9 9
10 type Annotation struct {10 type Annotation struct {
11- ID int6411+ ID int64
12- URI string12+ URI string
13- AuthorDID string13+ AuthorDID string
14- FeedURL string14+ AuthorHandle string
15- ArticleURL string15+ FeedURL string
16- Quote sql.NullString16+ ArticleURL string
17- Note sql.NullString17+ Quote sql.NullString
18- Tags sql.NullString18+ Note sql.NullString
19- Rating sql.NullInt6419+ Tags sql.NullString
20- CreatedAt sql.NullTime20+ Rating sql.NullInt64
21- CID sql.NullString21+ CreatedAt sql.NullTime
22+ CID sql.NullString
22 }23 }
23 24
24 type Like struct {25 type Like struct {
@@ -61,12 +62,13 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
61 args = append(args, authorDID)62 args = append(args, authorDID)
62 }63 }
63 64
64- query := `SELECT id, uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid65+ query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid
65- FROM annotations`66+ FROM annotations a
67+ LEFT JOIN users u ON a.author_did = u.did`
66 if len(conds) > 0 {68 if len(conds) > 0 {
67 query += ` WHERE ` + strings.Join(conds, " AND ")69 query += ` WHERE ` + strings.Join(conds, " AND ")
68 }70 }
69- query += fmt.Sprintf(` ORDER BY created_at DESC LIMIT %d OFFSET %d`, limit, offset)71+ query += fmt.Sprintf(` ORDER BY a.created_at DESC LIMIT %d OFFSET %d`, limit, offset)
70 72
71 rows, err := db.QueryContext(ctx, query, args...)73 rows, err := db.QueryContext(ctx, query, args...)
72 if err != nil {74 if err != nil {
@@ -77,7 +79,7 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
77 var annotations []*Annotation79 var annotations []*Annotation
78 for rows.Next() {80 for rows.Next() {
79 a := &Annotation{}81 a := &Annotation{}
80- if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.FeedURL, &a.ArticleURL,82+ if err := rows.Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL,
81 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {83 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID); err != nil {
82 return nil, err84 return nil, err
83 }85 }
modified internal/server/auth_handler.go +31 -24
@@ -1,15 +1,17 @@
11 package server
22
33 import (
4+ "context"
45 "encoding/json"
56 "fmt"
67 "net/http"
78 "net/url"
89 "os"
910
10- oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111 "github.com/bluesky-social/indigo/atproto/syntax"
1212
13+ oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
14+
1315 "pkg.rbrt.fr/glean/internal/atproto"
1416 )
1517
@@ -92,7 +94,9 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
9294 handle = ident.Handle.String()
9395 }
9496
95- user, err := s.db.CreateUser(r.Context(), did, handle, "", "")
97+ displayName, avatarURL := s.fetchUserProfile(r.Context(), sessData)
98+
99+ user, err := s.db.CreateUser(r.Context(), did, handle, displayName, avatarURL)
96100 if err != nil {
97101 s.logger.Error("failed to create user", "error", err)
98102 http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -123,35 +127,38 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
123127 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
124128 }
125129
126-func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
127- clientID := s.clientID
128- if clientID == "" {
129- scheme := "http"
130- if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
131- scheme = "https"
132- }
133- clientID = fmt.Sprintf("%s://%s/oauth/client-metadata", scheme, r.Host)
130+func (s *Server) fetchUserProfile(ctx context.Context, sessData *oauth.ClientSessionData) (string, string) {
131+ did := sessData.AccountDID.String()
132+
133+ session, err := s.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
134+ if err != nil {
135+ s.logger.Warn("failed to resume session for profile fetch", "error", err)
136+ return "", ""
134137 }
135138
136- redirectURL := s.callbackURL
137- if redirectURL == "" {
138- scheme := "http"
139- if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
140- scheme = "https"
141- }
142- redirectURL = fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
139+ var profile struct {
140+ DisplayName string `json:"displayName"`
141+ Avatar string `json:"avatar"`
143142 }
144143
145- config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"})
146- meta := config.ClientMetadata()
144+ nsid, _ := syntax.ParseNSID("app.bsky.actor.getProfile")
145+ if err := session.APIClient().Get(ctx, nsid, map[string]any{"actor": did}, &profile); err != nil {
146+ s.logger.Warn("failed to fetch profile from PDS", "error", err, "did", did)
147+ return "", ""
148+ }
149+ return profile.DisplayName, profile.Avatar
150+}
147151
152+func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
153+ if s.clientID == "" {
154+ http.Error(w, "localhost client", http.StatusNotFound)
155+ return
156+ }
157+
158+ meta := s.oauth.Config.ClientMetadata()
148159 name := "Glean"
149160 meta.ClientName = &name
150-
151- uri := clientID
152- if idx := len(uri); idx > 0 && uri[idx-1] == '/' {
153- uri = uri[:idx-1]
154- }
161+ uri := s.clientID
155162 meta.ClientURI = &uri
156163
157164 w.Header().Set("Content-Type", "application/json")
@@ -1,15 +1,17 @@
1 package server1 package server
2 2
3 import (3 import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "fmt"6 "fmt"
6 "net/http"7 "net/http"
7 "net/url"8 "net/url"
8 "os"9 "os"
9 10
10- oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
11 "github.com/bluesky-social/indigo/atproto/syntax"11 "github.com/bluesky-social/indigo/atproto/syntax"
12 12
13+ oauth "github.com/bluesky-social/indigo/atproto/auth/oauth"
14+
13 "pkg.rbrt.fr/glean/internal/atproto"15 "pkg.rbrt.fr/glean/internal/atproto"
14 )16 )
15 17
@@ -92,7 +94,9 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
92 handle = ident.Handle.String()94 handle = ident.Handle.String()
93 }95 }
94 96
95- user, err := s.db.CreateUser(r.Context(), did, handle, "", "")97+ displayName, avatarURL := s.fetchUserProfile(r.Context(), sessData)
98+
99+ user, err := s.db.CreateUser(r.Context(), did, handle, displayName, avatarURL)
96 if err != nil {100 if err != nil {
97 s.logger.Error("failed to create user", "error", err)101 s.logger.Error("failed to create user", "error", err)
98 http.Error(w, err.Error(), http.StatusInternalServerError)102 http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -123,35 +127,38 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
123 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)127 http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
124 }128 }
125 129
126-func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {130+func (s *Server) fetchUserProfile(ctx context.Context, sessData *oauth.ClientSessionData) (string, string) {
127- clientID := s.clientID131+ did := sessData.AccountDID.String()
128- if clientID == "" {132+
129- scheme := "http"133+ session, err := s.oauth.ResumeSession(ctx, sessData.AccountDID, sessData.SessionID)
130- if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {134+ if err != nil {
131- scheme = "https"135+ s.logger.Warn("failed to resume session for profile fetch", "error", err)
132- }136+ return "", ""
133- clientID = fmt.Sprintf("%s://%s/oauth/client-metadata", scheme, r.Host)
134 }137 }
135 138
136- redirectURL := s.callbackURL139+ var profile struct {
137- if redirectURL == "" {140+ DisplayName string `json:"displayName"`
138- scheme := "http"141+ Avatar string `json:"avatar"`
139- if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
140- scheme = "https"
141- }
142- redirectURL = fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
143 }142 }
144 143
145- config := oauth.NewPublicConfig(clientID, redirectURL, []string{"atproto"})144+ nsid, _ := syntax.ParseNSID("app.bsky.actor.getProfile")
146- meta := config.ClientMetadata()145+ if err := session.APIClient().Get(ctx, nsid, map[string]any{"actor": did}, &profile); err != nil {
146+ s.logger.Warn("failed to fetch profile from PDS", "error", err, "did", did)
147+ return "", ""
148+ }
149+ return profile.DisplayName, profile.Avatar
150+}
147 151
152+func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
153+ if s.clientID == "" {
154+ http.Error(w, "localhost client", http.StatusNotFound)
155+ return
156+ }
157+
158+ meta := s.oauth.Config.ClientMetadata()
148 name := "Glean"159 name := "Glean"
149 meta.ClientName = &name160 meta.ClientName = &name
150-161+ uri := s.clientID
151- uri := clientID
152- if idx := len(uri); idx > 0 && uri[idx-1] == '/' {
153- uri = uri[:idx-1]
154- }
155 meta.ClientURI = &uri162 meta.ClientURI = &uri
156 163
157 w.Header().Set("Content-Type", "application/json")164 w.Header().Set("Content-Type", "application/json")
modified internal/server/profile_handler.go +2 -2
@@ -19,8 +19,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
1919 subCount, _ := s.db.GetSubscriptionCount(r.Context(), did)
2020
2121 s.render(w, r, "profile.html", map[string]any{
22- "User": s.getUserFromSession(r),
23- "ProfileUser": profileUser,
22+ "User": currentUser(r),
23+ "ProfileUser": profileUser,
2424 "Subscriptions": subs,
2525 "Annotations": annotations,
2626 "SubscriptionCount": subCount,
@@ -19,8 +19,8 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
19 subCount, _ := s.db.GetSubscriptionCount(r.Context(), did)19 subCount, _ := s.db.GetSubscriptionCount(r.Context(), did)
20 20
21 s.render(w, r, "profile.html", map[string]any{21 s.render(w, r, "profile.html", map[string]any{
22- "User": s.getUserFromSession(r),22+ "User": currentUser(r),
23- "ProfileUser": profileUser,23+ "ProfileUser": profileUser,
24 "Subscriptions": subs,24 "Subscriptions": subs,
25 "Annotations": annotations,25 "Annotations": annotations,
26 "SubscriptionCount": subCount,26 "SubscriptionCount": subCount,
modified internal/server/server.go +17 -3
@@ -3,6 +3,7 @@ package server
33 import (
44 "bytes"
55 "context"
6+ "fmt"
67 "html/template"
78 "log/slog"
89 "net/http"
@@ -39,13 +40,23 @@ type Server struct {
3940 callbackURL string
4041 }
4142
42-func New(database *db.DB, clientID, callbackURL string, logger *slog.Logger) *Server {
43+func New(database *db.DB, clientID, callbackURL, addr string, logger *slog.Logger) *Server {
4344 oauthStore := db.NewOAuthStore(database)
4445 if err := oauthStore.Init(context.Background()); err != nil {
4546 logger.Error("failed to init oauth store", "error", err)
4647 }
4748
48- config := oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto"})
49+ var config oauth.ClientConfig
50+ if clientID == "" {
51+ host := addr
52+ if strings.HasPrefix(host, ":") {
53+ host = "127.0.0.1" + host
54+ }
55+ cbURL := fmt.Sprintf("http://%s/auth/callback", host)
56+ config = oauth.NewLocalhostConfig(cbURL, []string{"atproto", "transition:generic"})
57+ } else {
58+ config = oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto", "transition:generic"})
59+ }
4960 oauthClient := oauth.NewClientApp(&config, oauthStore)
5061
5162 s := &Server{
@@ -123,7 +134,10 @@ func (s *Server) setupRoutes() {
123134 r.Get("/people", s.handleDiscoverPeople)
124135 })
125136
126- s.router.Get("/profile/{did}", s.handleProfile)
137+ s.router.Route("/profile", func(r chi.Router) {
138+ r.Use(s.requireAuth)
139+ r.Get("/{did}", s.handleProfile)
140+ })
127141
128142 s.router.Route("/annotations", func(r chi.Router) {
129143 r.Use(s.requireAuth)
@@ -3,6 +3,7 @@ package server
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "fmt"
6 "html/template"7 "html/template"
7 "log/slog"8 "log/slog"
8 "net/http"9 "net/http"
@@ -39,13 +40,23 @@ type Server struct {
39 callbackURL string40 callbackURL string
40 }41 }
41 42
42-func New(database *db.DB, clientID, callbackURL string, logger *slog.Logger) *Server {43+func New(database *db.DB, clientID, callbackURL, addr string, logger *slog.Logger) *Server {
43 oauthStore := db.NewOAuthStore(database)44 oauthStore := db.NewOAuthStore(database)
44 if err := oauthStore.Init(context.Background()); err != nil {45 if err := oauthStore.Init(context.Background()); err != nil {
45 logger.Error("failed to init oauth store", "error", err)46 logger.Error("failed to init oauth store", "error", err)
46 }47 }
47 48
48- config := oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto"})49+ var config oauth.ClientConfig
50+ if clientID == "" {
51+ host := addr
52+ if strings.HasPrefix(host, ":") {
53+ host = "127.0.0.1" + host
54+ }
55+ cbURL := fmt.Sprintf("http://%s/auth/callback", host)
56+ config = oauth.NewLocalhostConfig(cbURL, []string{"atproto", "transition:generic"})
57+ } else {
58+ config = oauth.NewPublicConfig(clientID, callbackURL, []string{"atproto", "transition:generic"})
59+ }
49 oauthClient := oauth.NewClientApp(&config, oauthStore)60 oauthClient := oauth.NewClientApp(&config, oauthStore)
50 61
51 s := &Server{62 s := &Server{
@@ -123,7 +134,10 @@ func (s *Server) setupRoutes() {
123 r.Get("/people", s.handleDiscoverPeople)134 r.Get("/people", s.handleDiscoverPeople)
124 })135 })
125 136
126- s.router.Get("/profile/{did}", s.handleProfile)137+ s.router.Route("/profile", func(r chi.Router) {
138+ r.Use(s.requireAuth)
139+ r.Get("/{did}", s.handleProfile)
140+ })
127 141
128 s.router.Route("/annotations", func(r chi.Router) {142 s.router.Route("/annotations", func(r chi.Router) {
129 r.Use(s.requireAuth)143 r.Use(s.requireAuth)
modified internal/tmpl/article_detail.html +1 -0
@@ -29,6 +29,7 @@
2929 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
3030 target="_blank" rel="noopener noreferrer"
3131 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
32+ <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
3233 Share
3334 </a>
3435
@@ -29,6 +29,7 @@
29 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"29 <a href="https://bsky.app/intent/compose?text={{.Article.Title}}%20{{if .Article.URL.Valid}}{{.Article.URL.String}}{{end}}"
30 target="_blank" rel="noopener noreferrer"30 target="_blank" rel="noopener noreferrer"
31 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">31 class="text-xs border border-spot-outline text-spot-text rounded-pill px-3 py-1 hover:border-spot-text uppercase tracking-button transition inline-flex items-center gap-1">
32+ <span class="w-3.5 h-3.5 inline-flex">{{template "icon-bluesky"}}</span>
32 Share33 Share
33 </a>34 </a>
34 35
modified internal/tmpl/index.html +3 -1
@@ -3,7 +3,9 @@
33 <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1>
44 <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p>
55 <div class="flex flex-col sm:flex-row justify-center gap-3">
6- <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a>
6+ <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition inline-flex items-center gap-2">
7+ Sign in
8+ </a>
79 <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a>
810 </div>
911 <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
@@ -3,7 +3,9 @@
3 <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1>3 <h1 class="text-5xl font-bold font-title mb-6 text-spot-text">Glean</h1>
4 <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p>4 <p class="text-lg text-spot-secondary mb-10 max-w-lg mx-auto leading-relaxed">A social RSS reader. Follow feeds, share what you read, discover new sources through your network.</p>
5 <div class="flex flex-col sm:flex-row justify-center gap-3">5 <div class="flex flex-col sm:flex-row justify-center gap-3">
6- <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">Sign in with Bluesky</a>6+ <a href="/auth/login" class="bg-spot-purple text-spot-bg rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition inline-flex items-center gap-2">
7+ Sign in
8+ </a>
7 <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a>9 <a href="/trending" class="border border-spot-outline text-spot-text rounded-pill px-8 py-3 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Browse trending</a>
8 </div>10 </div>
9 <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">11 <div class="mt-24 grid grid-cols-1 sm:grid-cols-3 gap-4 text-left">
modified internal/tmpl/login.html +18 -7
@@ -1,18 +1,29 @@
11 {{define "login.html"}}
22 <div class="max-w-md mx-auto mt-20">
33 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
4- <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1>
5- <form action="/auth/start" method="POST">
4+ <h1 class="text-2xl font-bold font-title text-spot-text mb-2">Sign in to Glean</h1>
5+ <p class="text-spot-secondary text-sm mb-6">Enter your handle.</p>
6+
7+ <form action="/auth/start" method="POST" id="login-form">
68 {{csrfInput .CSRFToken}}
7- <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
89 <input type="text" name="handle" placeholder="you.bsky.social"
9- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
10+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
1011 required>
11- <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
12+ </form>
13+
14+ <div class="space-y-3">
15+ <button type="submit" form="login-form"
16+ class="w-full flex items-center justify-center gap-3 bg-[#0284FF] text-white rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
17+ {{template "icon-bluesky"}}
1218 Sign in with Bluesky
1319 </button>
14- </form>
15- <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account using AT Protocol OAuth.</p>
20+
21+ <button type="submit" form="login-form"
22+ class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
23+ {{template "icon-globe"}}
24+ Sign in with Atmosphere
25+ </button>
26+ </div>
1627 </div>
1728 </div>
1829 {{end}}
@@ -1,18 +1,29 @@
1 {{define "login.html"}}1 {{define "login.html"}}
2 <div class="max-w-md mx-auto mt-20">2 <div class="max-w-md mx-auto mt-20">
3 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">3 <div class="bg-spot-surface rounded-lg shadow-spot-heavy p-8">
4- <h1 class="text-2xl font-bold font-title text-spot-text mb-6">Sign in to Glean</h1>4+ <h1 class="text-2xl font-bold font-title text-spot-text mb-2">Sign in to Glean</h1>
5- <form action="/auth/start" method="POST">5+ <p class="text-spot-secondary text-sm mb-6">Enter your handle.</p>
6+
7+ <form action="/auth/start" method="POST" id="login-form">
6 {{csrfInput .CSRFToken}}8 {{csrfInput .CSRFToken}}
7- <label class="block text-sm font-bold text-spot-secondary mb-2 uppercase tracking-button">Bluesky Handle</label>
8 <input type="text" name="handle" placeholder="you.bsky.social"9 <input type="text" name="handle" placeholder="you.bsky.social"
9- class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"10+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 mb-5 text-sm focus:outline-none focus:ring-2 focus:ring-spot-purple placeholder:text-spot-placeholder"
10 required>11 required>
11- <button type="submit" class="w-full bg-spot-purple text-spot-bg rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">12+ </form>
13+
14+ <div class="space-y-3">
15+ <button type="submit" form="login-form"
16+ class="w-full flex items-center justify-center gap-3 bg-[#0284FF] text-white rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:brightness-110 transition">
17+ {{template "icon-bluesky"}}
12 Sign in with Bluesky18 Sign in with Bluesky
13 </button>19 </button>
14- </form>20+
15- <p class="mt-5 text-sm text-spot-secondary">We'll authenticate you via your Bluesky account using AT Protocol OAuth.</p>21+ <button type="submit" form="login-form"
22+ class="w-full flex items-center justify-center gap-3 border border-spot-outline text-spot-text rounded-pill px-4 py-3 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
23+ {{template "icon-globe"}}
24+ Sign in with Atmosphere
25+ </button>
26+ </div>
16 </div>27 </div>
17 </div>28 </div>
18 {{end}}29 {{end}}
modified internal/tmpl/partials/annotation-card.html +1 -1
@@ -15,7 +15,7 @@
1515 <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div>
1616 {{end}}
1717 <div class="text-xs text-spot-muted mt-2">
18- <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a>
18+ <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorHandle}}</a>
1919 {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
2020 </div>
2121 </div>
@@ -15,7 +15,7 @@
15 <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div>15 <div class="text-sm text-spot-orange mt-1">{{repeat "&#9733;" (int .Rating.Int64)}}</div>
16 {{end}}16 {{end}}
17 <div class="text-xs text-spot-muted mt-2">17 <div class="text-xs text-spot-muted mt-2">
18- <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorDID}}</a>18+ <a href="/profile/{{.AuthorDID}}" class="hover:text-spot-purple transition">{{.AuthorHandle}}</a>
19 {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}19 {{if .CreatedAt.Valid}}<span class="ml-2">{{.CreatedAt.Time.Format "Jan 2, 2006 15:04"}}</span>{{end}}
20 </div>20 </div>
21 </div>21 </div>
added internal/tmpl/partials/icon-bluesky.html +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+{{define "icon-bluesky"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}}
new file mode 100644
@@ -0,0 +1 @@
1+{{define "icon-bluesky"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026"/></svg>{{end}}
added internal/tmpl/partials/icon-globe.html +1 -0
new file mode 100644
@@ -0,0 +1 @@
1+{{define "icon-globe"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/></svg>{{end}}
new file mode 100644
@@ -0,0 +1 @@
1+{{define "icon-globe"}}<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/><line x1="2" y1="12" x2="22" y2="12"/></svg>{{end}}
modified main.go +1 -1
@@ -36,7 +36,7 @@ func main() {
3636 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
3737 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
3838
39- srv := server.New(database, clientID, callbackURL, logger)
39+ srv := server.New(database, clientID, callbackURL, *addr, logger)
4040
4141 storeAdapter := db.NewFeedStoreAdapter(database)
4242 scheduler := feed.NewScheduler(storeAdapter, logger)
@@ -36,7 +36,7 @@ func main() {
36 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")36 clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "")
37 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")37 callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "")
38 38
39- srv := server.New(database, clientID, callbackURL, logger)39+ srv := server.New(database, clientID, callbackURL, *addr, logger)
40 40
41 storeAdapter := db.NewFeedStoreAdapter(database)41 storeAdapter := db.NewFeedStoreAdapter(database)
42 scheduler := feed.NewScheduler(storeAdapter, logger)42 scheduler := feed.NewScheduler(storeAdapter, logger)