nandi/gleanpublic⑂ Fork 0
⑂ decfb0a
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 auto complete on login pageUnverified

Julien Robert committed 2026-04-28T23:16:15+02:00 Browse files
decfb0a parent: bc9e3fb
modified internal/atproto/auth.go +5 -5
@@ -146,16 +146,16 @@ func ResolveProfile(ctx context.Context, did string) Profile {
146146 Handle: ident.Handle.String(),
147147 }
148148
149- h, dn, avatar, err := FetchProfile(ctx, did)
149+ actor, err := FetchProfile(ctx, did)
150150 if err != nil {
151151 return p
152152 }
153153
154- if h != "" {
155- p.Handle = h
154+ if actor.Handle != "" {
155+ p.Handle = actor.Handle
156156 }
157- p.DisplayName = dn
158- p.AvatarURL = avatar
157+ p.DisplayName = actor.DisplayName
158+ p.AvatarURL = actor.Avatar
159159
160160 profileCache.Store(did, &profileEntry{profile: p, fetched: time.Now()})
161161 return p
@@ -146,16 +146,16 @@ func ResolveProfile(ctx context.Context, did string) Profile {
146 Handle: ident.Handle.String(),146 Handle: ident.Handle.String(),
147 }147 }
148 148
149- h, dn, avatar, err := FetchProfile(ctx, did)149+ actor, err := FetchProfile(ctx, did)
150 if err != nil {150 if err != nil {
151 return p151 return p
152 }152 }
153 153
154- if h != "" {154+ if actor.Handle != "" {
155- p.Handle = h155+ p.Handle = actor.Handle
156 }156 }
157- p.DisplayName = dn157+ p.DisplayName = actor.DisplayName
158- p.AvatarURL = avatar158+ p.AvatarURL = actor.Avatar
159 159
160 profileCache.Store(did, &profileEntry{profile: p, fetched: time.Now()})160 profileCache.Store(did, &profileEntry{profile: p, fetched: time.Now()})
161 return p161 return p
modified internal/atproto/profile.go +45 -12
@@ -9,37 +9,70 @@ import (
99 "time"
1010 )
1111
12+type Actor struct {
13+ DID string `json:"did"`
14+ Handle string `json:"handle"`
15+ DisplayName string `json:"displayName"`
16+ Avatar string `json:"avatar"`
17+}
18+
1219 var profileClient = &http.Client{Timeout: 10 * time.Second}
1320
1421 // FetchProfile is an unauthenticated profile fetcher. It relies on bluesky api.
15-func FetchProfile(ctx context.Context, identifier string) (handle, displayName, avatarURL string, err error) {
22+func FetchProfile(ctx context.Context, identifier string) (*Actor, error) {
1623 apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", url.QueryEscape(identifier))
1724 return fetchProfileFromURL(ctx, apiURL, identifier)
1825 }
1926
20-func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (handle, displayName, avatarURL string, err error) {
27+func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (*Actor, error) {
28+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
29+ if err != nil {
30+ return nil, err
31+ }
32+
33+ resp, err := profileClient.Do(req)
34+ if err != nil {
35+ return nil, err
36+ }
37+ defer resp.Body.Close()
38+
39+ if resp.StatusCode != http.StatusOK {
40+ return nil, fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode)
41+ }
42+
43+ var actor Actor
44+ if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
45+ return nil, err
46+ }
47+ return &actor, nil
48+}
49+
50+func SearchActorsTypeahead(ctx context.Context, query string, limit int) ([]Actor, error) {
51+ if limit <= 0 {
52+ limit = 5
53+ }
54+ apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=%s&limit=%d", url.QueryEscape(query), limit)
55+
2156 req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
2257 if err != nil {
23- return "", "", "", err
58+ return nil, err
2459 }
2560
2661 resp, err := profileClient.Do(req)
2762 if err != nil {
28- return "", "", "", err
63+ return nil, err
2964 }
3065 defer resp.Body.Close()
3166
3267 if resp.StatusCode != http.StatusOK {
33- return "", "", "", fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode)
68+ return nil, fmt.Errorf("search actors typeahead: status %d", resp.StatusCode)
3469 }
3570
36- var profile struct {
37- Handle string `json:"handle"`
38- DisplayName string `json:"displayName"`
39- Avatar string `json:"avatar"`
71+ var result struct {
72+ Actors []Actor `json:"actors"`
4073 }
41- if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
42- return "", "", "", err
74+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
75+ return nil, err
4376 }
44- return profile.Handle, profile.DisplayName, profile.Avatar, nil
77+ return result.Actors, nil
4578 }
@@ -9,37 +9,70 @@ import (
9 "time"9 "time"
10 )10 )
11 11
12+type Actor struct {
13+ DID string `json:"did"`
14+ Handle string `json:"handle"`
15+ DisplayName string `json:"displayName"`
16+ Avatar string `json:"avatar"`
17+}
18+
12 var profileClient = &http.Client{Timeout: 10 * time.Second}19 var profileClient = &http.Client{Timeout: 10 * time.Second}
13 20
14 // FetchProfile is an unauthenticated profile fetcher. It relies on bluesky api.21 // FetchProfile is an unauthenticated profile fetcher. It relies on bluesky api.
15-func FetchProfile(ctx context.Context, identifier string) (handle, displayName, avatarURL string, err error) {22+func FetchProfile(ctx context.Context, identifier string) (*Actor, error) {
16 apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", url.QueryEscape(identifier))23 apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", url.QueryEscape(identifier))
17 return fetchProfileFromURL(ctx, apiURL, identifier)24 return fetchProfileFromURL(ctx, apiURL, identifier)
18 }25 }
19 26
20-func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (handle, displayName, avatarURL string, err error) {27+func fetchProfileFromURL(ctx context.Context, apiURL, identifier string) (*Actor, error) {
28+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
29+ if err != nil {
30+ return nil, err
31+ }
32+
33+ resp, err := profileClient.Do(req)
34+ if err != nil {
35+ return nil, err
36+ }
37+ defer resp.Body.Close()
38+
39+ if resp.StatusCode != http.StatusOK {
40+ return nil, fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode)
41+ }
42+
43+ var actor Actor
44+ if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
45+ return nil, err
46+ }
47+ return &actor, nil
48+}
49+
50+func SearchActorsTypeahead(ctx context.Context, query string, limit int) ([]Actor, error) {
51+ if limit <= 0 {
52+ limit = 5
53+ }
54+ apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=%s&limit=%d", url.QueryEscape(query), limit)
55+
21 req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)56 req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
22 if err != nil {57 if err != nil {
23- return "", "", "", err58+ return nil, err
24 }59 }
25 60
26 resp, err := profileClient.Do(req)61 resp, err := profileClient.Do(req)
27 if err != nil {62 if err != nil {
28- return "", "", "", err63+ return nil, err
29 }64 }
30 defer resp.Body.Close()65 defer resp.Body.Close()
31 66
32 if resp.StatusCode != http.StatusOK {67 if resp.StatusCode != http.StatusOK {
33- return "", "", "", fmt.Errorf("fetch profile %s: status %d", identifier, resp.StatusCode)68+ return nil, fmt.Errorf("search actors typeahead: status %d", resp.StatusCode)
34 }69 }
35 70
36- var profile struct {71+ var result struct {
37- Handle string `json:"handle"`72+ Actors []Actor `json:"actors"`
38- DisplayName string `json:"displayName"`
39- Avatar string `json:"avatar"`
40 }73 }
41- if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {74+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
42- return "", "", "", err75+ return nil, err
43 }76 }
44- return profile.Handle, profile.DisplayName, profile.Avatar, nil77+ return result.Actors, nil
45 }78 }
modified internal/atproto/profile_test.go +14 -14
@@ -14,10 +14,10 @@ import (
1414 func TestFetchProfile_Success(t *testing.T) {
1515 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1616 assert.Equal(t, r.URL.Query().Get("actor"), "did:plc:test123")
17- json.NewEncoder(w).Encode(map[string]string{
18- "handle": "test.bsky.social",
19- "displayName": "Test User",
20- "avatar": "https://cdn.bsky.app/img/avatar/test.png",
17+ json.NewEncoder(w).Encode(Actor{
18+ Handle: "test.bsky.social",
19+ DisplayName: "Test User",
20+ Avatar: "https://cdn.bsky.app/img/avatar/test.png",
2121 })
2222 }))
2323 defer srv.Close()
@@ -27,16 +27,16 @@ func TestFetchProfile_Success(t *testing.T) {
2727 defer func() { profileClient = origClient }()
2828
2929 apiURL := srv.URL + "/xrpc/app.bsky.actor.getProfile?actor=" + url.QueryEscape("did:plc:test123")
30- handle, dn, avatar, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123")
30+ actor, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123")
3131 assert.NilError(t, err)
32- assert.Equal(t, handle, "test.bsky.social")
33- assert.Equal(t, dn, "Test User")
34- assert.Equal(t, avatar, "https://cdn.bsky.app/img/avatar/test.png")
32+ assert.Equal(t, actor.Handle, "test.bsky.social")
33+ assert.Equal(t, actor.DisplayName, "Test User")
34+ assert.Equal(t, actor.Avatar, "https://cdn.bsky.app/img/avatar/test.png")
3535 }
3636
3737 func TestFetchProfile_EmptyProfile(t *testing.T) {
3838 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39- json.NewEncoder(w).Encode(map[string]string{})
39+ json.NewEncoder(w).Encode(Actor{})
4040 }))
4141 defer srv.Close()
4242
@@ -44,11 +44,11 @@ func TestFetchProfile_EmptyProfile(t *testing.T) {
4444 profileClient = srv.Client()
4545 defer func() { profileClient = origClient }()
4646
47- handle, dn, avatar, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
47+ actor, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
4848 assert.NilError(t, err)
49- assert.Equal(t, handle, "")
50- assert.Equal(t, dn, "")
51- assert.Equal(t, avatar, "")
49+ assert.Equal(t, actor.Handle, "")
50+ assert.Equal(t, actor.DisplayName, "")
51+ assert.Equal(t, actor.Avatar, "")
5252 }
5353
5454 func TestFetchProfile_Non200(t *testing.T) {
@@ -61,6 +61,6 @@ func TestFetchProfile_Non200(t *testing.T) {
6161 profileClient = srv.Client()
6262 defer func() { profileClient = origClient }()
6363
64- _, _, _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
64+ _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
6565 assert.Assert(t, err != nil)
6666 }
@@ -14,10 +14,10 @@ import (
14 func TestFetchProfile_Success(t *testing.T) {14 func TestFetchProfile_Success(t *testing.T) {
15 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {15 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
16 assert.Equal(t, r.URL.Query().Get("actor"), "did:plc:test123")16 assert.Equal(t, r.URL.Query().Get("actor"), "did:plc:test123")
17- json.NewEncoder(w).Encode(map[string]string{17+ json.NewEncoder(w).Encode(Actor{
18- "handle": "test.bsky.social",18+ Handle: "test.bsky.social",
19- "displayName": "Test User",19+ DisplayName: "Test User",
20- "avatar": "https://cdn.bsky.app/img/avatar/test.png",20+ Avatar: "https://cdn.bsky.app/img/avatar/test.png",
21 })21 })
22 }))22 }))
23 defer srv.Close()23 defer srv.Close()
@@ -27,16 +27,16 @@ func TestFetchProfile_Success(t *testing.T) {
27 defer func() { profileClient = origClient }()27 defer func() { profileClient = origClient }()
28 28
29 apiURL := srv.URL + "/xrpc/app.bsky.actor.getProfile?actor=" + url.QueryEscape("did:plc:test123")29 apiURL := srv.URL + "/xrpc/app.bsky.actor.getProfile?actor=" + url.QueryEscape("did:plc:test123")
30- handle, dn, avatar, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123")30+ actor, err := fetchProfileFromURL(context.Background(), apiURL, "did:plc:test123")
31 assert.NilError(t, err)31 assert.NilError(t, err)
32- assert.Equal(t, handle, "test.bsky.social")32+ assert.Equal(t, actor.Handle, "test.bsky.social")
33- assert.Equal(t, dn, "Test User")33+ assert.Equal(t, actor.DisplayName, "Test User")
34- assert.Equal(t, avatar, "https://cdn.bsky.app/img/avatar/test.png")34+ assert.Equal(t, actor.Avatar, "https://cdn.bsky.app/img/avatar/test.png")
35 }35 }
36 36
37 func TestFetchProfile_EmptyProfile(t *testing.T) {37 func TestFetchProfile_EmptyProfile(t *testing.T) {
38 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {38 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
39- json.NewEncoder(w).Encode(map[string]string{})39+ json.NewEncoder(w).Encode(Actor{})
40 }))40 }))
41 defer srv.Close()41 defer srv.Close()
42 42
@@ -44,11 +44,11 @@ func TestFetchProfile_EmptyProfile(t *testing.T) {
44 profileClient = srv.Client()44 profileClient = srv.Client()
45 defer func() { profileClient = origClient }()45 defer func() { profileClient = origClient }()
46 46
47- handle, dn, avatar, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")47+ actor, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
48 assert.NilError(t, err)48 assert.NilError(t, err)
49- assert.Equal(t, handle, "")49+ assert.Equal(t, actor.Handle, "")
50- assert.Equal(t, dn, "")50+ assert.Equal(t, actor.DisplayName, "")
51- assert.Equal(t, avatar, "")51+ assert.Equal(t, actor.Avatar, "")
52 }52 }
53 53
54 func TestFetchProfile_Non200(t *testing.T) {54 func TestFetchProfile_Non200(t *testing.T) {
@@ -61,6 +61,6 @@ func TestFetchProfile_Non200(t *testing.T) {
61 profileClient = srv.Client()61 profileClient = srv.Client()
62 defer func() { profileClient = origClient }()62 defer func() { profileClient = origClient }()
63 63
64- _, _, _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")64+ _, err := fetchProfileFromURL(context.Background(), srv.URL+"/xrpc/app.bsky.actor.getProfile", "did:plc:test123")
65 assert.Assert(t, err != nil)65 assert.Assert(t, err != nil)
66 }66 }
modified internal/server/auth_handler.go +20 -0
@@ -17,6 +17,26 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
1717 s.render(w, r, "login.html", map[string]any{})
1818 }
1919
20+func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) {
21+ q := strings.TrimPrefix(r.URL.Query().Get("q"), "@")
22+ if q == "" {
23+ w.Header().Set("Content-Type", "application/json")
24+ w.Write([]byte(`{"actors":[]}`))
25+ return
26+ }
27+
28+ actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5)
29+ if err != nil {
30+ s.logger.Warn("actor typeahead failed", "error", err)
31+ w.Header().Set("Content-Type", "application/json")
32+ w.Write([]byte(`{"actors":[]}`))
33+ return
34+ }
35+
36+ w.Header().Set("Content-Type", "application/json")
37+ json.NewEncoder(w).Encode(map[string]any{"actors": actors})
38+}
39+
2040 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
2141 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
2242 if handle == "" {
@@ -17,6 +17,26 @@ func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
17 s.render(w, r, "login.html", map[string]any{})17 s.render(w, r, "login.html", map[string]any{})
18 }18 }
19 19
20+func (s *Server) handleAuthResolve(w http.ResponseWriter, r *http.Request) {
21+ q := strings.TrimPrefix(r.URL.Query().Get("q"), "@")
22+ if q == "" {
23+ w.Header().Set("Content-Type", "application/json")
24+ w.Write([]byte(`{"actors":[]}`))
25+ return
26+ }
27+
28+ actors, err := atproto.SearchActorsTypeahead(r.Context(), q, 5)
29+ if err != nil {
30+ s.logger.Warn("actor typeahead failed", "error", err)
31+ w.Header().Set("Content-Type", "application/json")
32+ w.Write([]byte(`{"actors":[]}`))
33+ return
34+ }
35+
36+ w.Header().Set("Content-Type", "application/json")
37+ json.NewEncoder(w).Encode(map[string]any{"actors": actors})
38+}
39+
20 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {40 func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) {
21 handle := strings.TrimPrefix(r.FormValue("handle"), "@")41 handle := strings.TrimPrefix(r.FormValue("handle"), "@")
22 if handle == "" {42 if handle == "" {
modified internal/server/server.go +1 -0
@@ -204,6 +204,7 @@ func (s *Server) setupRoutes() {
204204 })
205205
206206 s.router.Get("/auth/login", s.handleAuthLogin)
207+ s.router.Get("/auth/resolve", s.handleAuthResolve)
207208 s.router.Post("/auth/start", s.handleAuthStart)
208209 s.router.Get("/auth/callback", s.handleAuthCallback)
209210 s.router.Post("/auth/logout", s.handleAuthLogout)
@@ -204,6 +204,7 @@ func (s *Server) setupRoutes() {
204 })204 })
205 205
206 s.router.Get("/auth/login", s.handleAuthLogin)206 s.router.Get("/auth/login", s.handleAuthLogin)
207+ s.router.Get("/auth/resolve", s.handleAuthResolve)
207 s.router.Post("/auth/start", s.handleAuthStart)208 s.router.Post("/auth/start", s.handleAuthStart)
208 s.router.Get("/auth/callback", s.handleAuthCallback)209 s.router.Get("/auth/callback", s.handleAuthCallback)
209 s.router.Post("/auth/logout", s.handleAuthLogout)210 s.router.Post("/auth/logout", s.handleAuthLogout)
modified internal/tmpl/base.html +1 -1
@@ -35,7 +35,7 @@
3535 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
3636 <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
3737 <meta name="theme-color" content="#00754A">
38- <meta name="apple-mobile-web-app-capable" content="yes">
38+ <meta name="mobile-web-app-capable" content="yes">
3939 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
4040 <link rel="manifest" href="/static/manifest.json">
4141 <link rel="preconnect" href="https://fonts.googleapis.com">
@@ -35,7 +35,7 @@
35 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">35 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
36 <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">36 <link rel="apple-touch-icon" href="/static/apple-touch-icon.png">
37 <meta name="theme-color" content="#00754A">37 <meta name="theme-color" content="#00754A">
38- <meta name="apple-mobile-web-app-capable" content="yes">38+ <meta name="mobile-web-app-capable" content="yes">
39 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">39 <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
40 <link rel="manifest" href="/static/manifest.json">40 <link rel="manifest" href="/static/manifest.json">
41 <link rel="preconnect" href="https://fonts.googleapis.com">41 <link rel="preconnect" href="https://fonts.googleapis.com">
modified internal/tmpl/login.html +85 -4
@@ -9,9 +9,12 @@
99
1010 <form action="/auth/start" method="POST" id="login-form">
1111 {{csrfInput .CSRFToken}}
12- <input type="text" name="handle" placeholder="you.bsky.social"
13- 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-green placeholder:text-spot-placeholder"
14- required>
12+ <div class="relative mb-5">
13+ <input type="text" name="handle" placeholder="you.bsky.social" id="handle-input"
14+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
15+ required autocomplete="off">
16+ <div id="handle-suggestions" class="absolute left-0 right-0 top-full mt-1 bg-spot-surface border border-spot-divider rounded-xl shadow-spot-heavy z-50 overflow-hidden hidden"></div>
17+ </div>
1518 </form>
1619
1720 <div class="space-y-3">
@@ -30,7 +33,7 @@
3033
3134 <div class="mt-6 pt-6 border-t border-spot-divider text-center">
3235 <p class="text-xs text-spot-secondary mb-3">No account yet?</p>
33- <a href="https://eurosky.social" target="_blank" rel="noopener noreferrer"
36+ <a href="https://portal.eurosky.tech/create-account" target="_blank" rel="noopener noreferrer"
3437 class="inline-flex items-center justify-center gap-2 w-full border border-spot-outline text-spot-text rounded-pill px-4 py-2.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
3538 <span class="text-base">&#127466;&#127482;</span>
3639 Create an account on Eurosky
@@ -38,4 +41,82 @@
3841 </div>
3942 </div>
4043 </div>
44+<script>
45+(function() {
46+ var input = document.getElementById('handle-input');
47+ var box = document.getElementById('handle-suggestions');
48+ var timer = null;
49+ var selected = -1;
50+
51+ function close() {
52+ box.classList.add('hidden');
53+ box.innerHTML = '';
54+ selected = -1;
55+ }
56+
57+ function highlight(items, idx) {
58+ items.forEach(function(el, i) {
59+ el.classList.toggle('bg-spot-hover', i === idx);
60+ });
61+ }
62+
63+ input.addEventListener('input', function() {
64+ clearTimeout(timer);
65+ var q = input.value.trim().replace(/^@/, '');
66+ if (q.length < 1) { close(); return; }
67+ timer = setTimeout(function() {
68+ fetch('/auth/resolve?q=' + encodeURIComponent(q))
69+ .then(function(r) { return r.json(); })
70+ .then(function(data) {
71+ if (!data.actors || !data.actors.length) { close(); return; }
72+ selected = -1;
73+ box.innerHTML = '';
74+ data.actors.forEach(function(a) {
75+ var row = document.createElement('button');
76+ row.type = 'button';
77+ row.className = 'w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-spot-hover transition';
78+ var img = a.avatar
79+ ? '<img src="' + a.avatar + '" class="w-8 h-8 rounded-full shrink-0">'
80+ : '<div class="w-8 h-8 rounded-full bg-spot-hover shrink-0"></div>';
81+ row.innerHTML = img +
82+ '<div class="min-w-0">' +
83+ '<div class="text-sm text-spot-text truncate">@' + a.handle + '</div>' +
84+ (a.displayName ? '<div class="text-xs text-spot-secondary truncate">' + a.displayName + '</div>' : '') +
85+ '</div>';
86+ row.addEventListener('click', function() {
87+ input.value = a.handle;
88+ close();
89+ });
90+ box.appendChild(row);
91+ });
92+ box.classList.remove('hidden');
93+ })
94+ .catch(function() { close(); });
95+ }, 250);
96+ });
97+
98+ input.addEventListener('keydown', function(e) {
99+ var items = box.querySelectorAll('button');
100+ if (!items.length) return;
101+ if (e.key === 'ArrowDown') {
102+ e.preventDefault();
103+ selected = Math.min(selected + 1, items.length - 1);
104+ highlight(items, selected);
105+ } else if (e.key === 'ArrowUp') {
106+ e.preventDefault();
107+ selected = Math.max(selected - 1, 0);
108+ highlight(items, selected);
109+ } else if (e.key === 'Enter' && selected >= 0) {
110+ e.preventDefault();
111+ items[selected].click();
112+ } else if (e.key === 'Escape') {
113+ close();
114+ }
115+ });
116+
117+ document.addEventListener('click', function(e) {
118+ if (!box.contains(e.target) && e.target !== input) close();
119+ });
120+})();
121+</script>
41122 {{end}}
@@ -9,9 +9,12 @@
9 9
10 <form action="/auth/start" method="POST" id="login-form">10 <form action="/auth/start" method="POST" id="login-form">
11 {{csrfInput .CSRFToken}}11 {{csrfInput .CSRFToken}}
12- <input type="text" name="handle" placeholder="you.bsky.social"12+ <div class="relative mb-5">
13- 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-green placeholder:text-spot-placeholder"13+ <input type="text" name="handle" placeholder="you.bsky.social" id="handle-input"
14- required>14+ class="w-full bg-spot-hover text-spot-text rounded-pill px-5 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-spot-green placeholder:text-spot-placeholder"
15+ required autocomplete="off">
16+ <div id="handle-suggestions" class="absolute left-0 right-0 top-full mt-1 bg-spot-surface border border-spot-divider rounded-xl shadow-spot-heavy z-50 overflow-hidden hidden"></div>
17+ </div>
15 </form>18 </form>
16 19
17 <div class="space-y-3">20 <div class="space-y-3">
@@ -30,7 +33,7 @@
30 33
31 <div class="mt-6 pt-6 border-t border-spot-divider text-center">34 <div class="mt-6 pt-6 border-t border-spot-divider text-center">
32 <p class="text-xs text-spot-secondary mb-3">No account yet?</p>35 <p class="text-xs text-spot-secondary mb-3">No account yet?</p>
33- <a href="https://eurosky.social" target="_blank" rel="noopener noreferrer"36+ <a href="https://portal.eurosky.tech/create-account" target="_blank" rel="noopener noreferrer"
34 class="inline-flex items-center justify-center gap-2 w-full border border-spot-outline text-spot-text rounded-pill px-4 py-2.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">37 class="inline-flex items-center justify-center gap-2 w-full border border-spot-outline text-spot-text rounded-pill px-4 py-2.5 text-sm font-bold uppercase tracking-button hover:bg-spot-hover-50 transition">
35 <span class="text-base">&#127466;&#127482;</span>38 <span class="text-base">&#127466;&#127482;</span>
36 Create an account on Eurosky39 Create an account on Eurosky
@@ -38,4 +41,82 @@
38 </div>41 </div>
39 </div>42 </div>
40 </div>43 </div>
44+<script>
45+(function() {
46+ var input = document.getElementById('handle-input');
47+ var box = document.getElementById('handle-suggestions');
48+ var timer = null;
49+ var selected = -1;
50+
51+ function close() {
52+ box.classList.add('hidden');
53+ box.innerHTML = '';
54+ selected = -1;
55+ }
56+
57+ function highlight(items, idx) {
58+ items.forEach(function(el, i) {
59+ el.classList.toggle('bg-spot-hover', i === idx);
60+ });
61+ }
62+
63+ input.addEventListener('input', function() {
64+ clearTimeout(timer);
65+ var q = input.value.trim().replace(/^@/, '');
66+ if (q.length < 1) { close(); return; }
67+ timer = setTimeout(function() {
68+ fetch('/auth/resolve?q=' + encodeURIComponent(q))
69+ .then(function(r) { return r.json(); })
70+ .then(function(data) {
71+ if (!data.actors || !data.actors.length) { close(); return; }
72+ selected = -1;
73+ box.innerHTML = '';
74+ data.actors.forEach(function(a) {
75+ var row = document.createElement('button');
76+ row.type = 'button';
77+ row.className = 'w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-spot-hover transition';
78+ var img = a.avatar
79+ ? '<img src="' + a.avatar + '" class="w-8 h-8 rounded-full shrink-0">'
80+ : '<div class="w-8 h-8 rounded-full bg-spot-hover shrink-0"></div>';
81+ row.innerHTML = img +
82+ '<div class="min-w-0">' +
83+ '<div class="text-sm text-spot-text truncate">@' + a.handle + '</div>' +
84+ (a.displayName ? '<div class="text-xs text-spot-secondary truncate">' + a.displayName + '</div>' : '') +
85+ '</div>';
86+ row.addEventListener('click', function() {
87+ input.value = a.handle;
88+ close();
89+ });
90+ box.appendChild(row);
91+ });
92+ box.classList.remove('hidden');
93+ })
94+ .catch(function() { close(); });
95+ }, 250);
96+ });
97+
98+ input.addEventListener('keydown', function(e) {
99+ var items = box.querySelectorAll('button');
100+ if (!items.length) return;
101+ if (e.key === 'ArrowDown') {
102+ e.preventDefault();
103+ selected = Math.min(selected + 1, items.length - 1);
104+ highlight(items, selected);
105+ } else if (e.key === 'ArrowUp') {
106+ e.preventDefault();
107+ selected = Math.max(selected - 1, 0);
108+ highlight(items, selected);
109+ } else if (e.key === 'Enter' && selected >= 0) {
110+ e.preventDefault();
111+ items[selected].click();
112+ } else if (e.key === 'Escape') {
113+ close();
114+ }
115+ });
116+
117+ document.addEventListener('click', function(e) {
118+ if (!box.contains(e.target) && e.target !== input) close();
119+ });
120+})();
121+</script>
41 {{end}}122 {{end}}