Add auto complete on login pageUnverified
decfb0a parent: bc9e3fb modified
internal/atproto/auth.go +5 -5 | @@ -146,16 +146,16 @@ func ResolveProfile(ctx context.Context, did string) Profile { | ||
| 146 | 146 | Handle: ident.Handle.String(), |
| 147 | 147 | } |
| 148 | 148 | |
| 149 | - h, dn, avatar, err := FetchProfile(ctx, did) | |
| 149 | + actor, err := FetchProfile(ctx, did) | |
| 150 | 150 | if err != nil { |
| 151 | 151 | return p |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | - if h != "" { | |
| 155 | - p.Handle = h | |
| 154 | + if actor.Handle != "" { | |
| 155 | + p.Handle = actor.Handle | |
| 156 | 156 | } |
| 157 | - p.DisplayName = dn | |
| 158 | - p.AvatarURL = avatar | |
| 157 | + p.DisplayName = actor.DisplayName | |
| 158 | + p.AvatarURL = actor.Avatar | |
| 159 | 159 | |
| 160 | 160 | profileCache.Store(did, &profileEntry{profile: p, fetched: time.Now()}) |
| 161 | 161 | 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 p | 151 | return p |
| 152 | } | 152 | } |
| 153 | 153 | ||
| 154 | - if h != "" { | 154 | + if actor.Handle != "" { |
| 155 | - p.Handle = h | 155 | + p.Handle = actor.Handle |
| 156 | } | 156 | } |
| 157 | - p.DisplayName = dn | 157 | + p.DisplayName = actor.DisplayName |
| 158 | - p.AvatarURL = avatar | 158 | + 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 p | 161 | return p |
modified
internal/atproto/profile.go +45 -12 | @@ -9,37 +9,70 @@ import ( | ||
| 9 | 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 | 19 | var profileClient = &http.Client{Timeout: 10 * time.Second} |
| 13 | 20 | |
| 14 | 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 | 23 | apiURL := fmt.Sprintf("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=%s", url.QueryEscape(identifier)) |
| 17 | 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 | 56 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) |
| 22 | 57 | if err != nil { |
| 23 | - return "", "", "", err | |
| 58 | + return nil, err | |
| 24 | 59 | } |
| 25 | 60 | |
| 26 | 61 | resp, err := profileClient.Do(req) |
| 27 | 62 | if err != nil { |
| 28 | - return "", "", "", err | |
| 63 | + return nil, err | |
| 29 | 64 | } |
| 30 | 65 | defer resp.Body.Close() |
| 31 | 66 | |
| 32 | 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 { | |
| 37 | - Handle string `json:"handle"` | |
| 38 | - DisplayName string `json:"displayName"` | |
| 39 | - Avatar string `json:"avatar"` | |
| 71 | + var result struct { | |
| 72 | + Actors []Actor `json:"actors"` | |
| 40 | 73 | } |
| 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 | |
| 43 | 76 | } |
| 44 | - return profile.Handle, profile.DisplayName, profile.Avatar, nil | |
| 77 | + return result.Actors, nil | |
| 45 | 78 | } |
| @@ -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 "", "", "", err | 58 | + 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 "", "", "", err | 63 | + 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 "", "", "", err | 75 | + return nil, err |
| 43 | } | 76 | } |
| 44 | - return profile.Handle, profile.DisplayName, profile.Avatar, nil | 77 | + return result.Actors, nil |
| 45 | } | 78 | } |
modified
internal/atproto/profile_test.go +14 -14 | @@ -14,10 +14,10 @@ import ( | ||
| 14 | 14 | func TestFetchProfile_Success(t *testing.T) { |
| 15 | 15 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 16 | 16 | 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", | |
| 21 | 21 | }) |
| 22 | 22 | })) |
| 23 | 23 | defer srv.Close() |
| @@ -27,16 +27,16 @@ func TestFetchProfile_Success(t *testing.T) { | ||
| 27 | 27 | defer func() { profileClient = origClient }() |
| 28 | 28 | |
| 29 | 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 | 31 | 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") | |
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | func TestFetchProfile_EmptyProfile(t *testing.T) { |
| 38 | 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 | 41 | defer srv.Close() |
| 42 | 42 | |
| @@ -44,11 +44,11 @@ func TestFetchProfile_EmptyProfile(t *testing.T) { | ||
| 44 | 44 | profileClient = srv.Client() |
| 45 | 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 | 48 | 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, "") | |
| 52 | 52 | } |
| 53 | 53 | |
| 54 | 54 | func TestFetchProfile_Non200(t *testing.T) { |
| @@ -61,6 +61,6 @@ func TestFetchProfile_Non200(t *testing.T) { | ||
| 61 | 61 | profileClient = srv.Client() |
| 62 | 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 | 65 | assert.Assert(t, err != nil) |
| 66 | 66 | } |
| @@ -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) { | ||
| 17 | 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 | 40 | func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { |
| 21 | 41 | handle := strings.TrimPrefix(r.FormValue("handle"), "@") |
| 22 | 42 | 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() { | ||
| 204 | 204 | }) |
| 205 | 205 | |
| 206 | 206 | s.router.Get("/auth/login", s.handleAuthLogin) |
| 207 | + s.router.Get("/auth/resolve", s.handleAuthResolve) | |
| 207 | 208 | s.router.Post("/auth/start", s.handleAuthStart) |
| 208 | 209 | s.router.Get("/auth/callback", s.handleAuthCallback) |
| 209 | 210 | 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 @@ | ||
| 35 | 35 | <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"> |
| 36 | 36 | <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"> |
| 37 | 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 | 39 | <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> |
| 40 | 40 | <link rel="manifest" href="/static/manifest.json"> |
| 41 | 41 | <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 @@ | ||
| 9 | 9 | |
| 10 | 10 | <form action="/auth/start" method="POST" id="login-form"> |
| 11 | 11 | {{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> | |
| 15 | 18 | </form> |
| 16 | 19 | |
| 17 | 20 | <div class="space-y-3"> |
| @@ -30,7 +33,7 @@ | ||
| 30 | 33 | |
| 31 | 34 | <div class="mt-6 pt-6 border-t border-spot-divider text-center"> |
| 32 | 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 | 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 | 38 | <span class="text-base">🇪🇺</span> |
| 36 | 39 | Create an account on Eurosky |
| @@ -38,4 +41,82 @@ | ||
| 38 | 41 | </div> |
| 39 | 42 | </div> |
| 40 | 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 | 122 | {{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">🇪🇺</span> | 38 | <span class="text-base">🇪🇺</span> |
| 36 | Create an account on Eurosky | 39 | 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}} |