diff --git a/templates.go b/templates.go new file mode 100644 index 0000000..15d4a5e --- /dev/null +++ b/templates.go @@ -0,0 +1,524 @@ +package main + +import ( + "encoding/json" + "html/template" + "io" + "net/http" + "os" + "time" +) + +// PDSAccount represents a Bluesky account on the PDS +type PDSAccount struct { + DID string `json:"did"` + Handle string `json:"handle"` + DisplayName string `json:"displayName"` + Description string `json:"description"` + Avatar string `json:"avatar"` +} + +// handleAccountsDirectory serves the 1440.news accounts directory page +func (d *Dashboard) handleAccountsDirectory(w http.ResponseWriter, r *http.Request) { + pdsHost := os.Getenv("PDS_HOST") + if pdsHost == "" { + pdsHost = "https://pds.1440.news" + } + + // Fetch all repos from PDS + listReposURL := pdsHost + "/xrpc/com.atproto.sync.listRepos?limit=1000" + resp, err := http.Get(listReposURL) + if err != nil { + http.Error(w, "Failed to fetch accounts: "+err.Error(), http.StatusInternalServerError) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + var reposResp struct { + Repos []struct { + DID string `json:"did"` + Head string `json:"head"` + Active bool `json:"active"` + } `json:"repos"` + } + if err := json.Unmarshal(body, &reposResp); err != nil { + http.Error(w, "Failed to parse repos: "+err.Error(), http.StatusInternalServerError) + return + } + + // Fetch profile for each account using unauthenticated endpoints + var accounts []PDSAccount + client := &http.Client{Timeout: 5 * time.Second} + + for _, repo := range reposResp.Repos { + if !repo.Active { + continue + } + + // Get handle using describeRepo + describeURL := pdsHost + "/xrpc/com.atproto.repo.describeRepo?repo=" + repo.DID + describeResp, err := client.Get(describeURL) + if err != nil { + continue + } + describeBody, _ := io.ReadAll(describeResp.Body) + describeResp.Body.Close() + + var repoInfo struct { + Handle string `json:"handle"` + DID string `json:"did"` + } + if err := json.Unmarshal(describeBody, &repoInfo); err != nil { + continue + } + + // Skip the main 1440.news account (directory account itself) + if repoInfo.Handle == "1440.news" { + continue + } + + account := PDSAccount{ + DID: repoInfo.DID, + Handle: repoInfo.Handle, + } + + // Get profile record for display name, description, avatar + recordURL := pdsHost + "/xrpc/com.atproto.repo.getRecord?repo=" + repo.DID + "&collection=app.bsky.actor.profile&rkey=self" + recordResp, err := client.Get(recordURL) + if err == nil { + recordBody, _ := io.ReadAll(recordResp.Body) + recordResp.Body.Close() + + var record struct { + Value struct { + DisplayName string `json:"displayName"` + Description string `json:"description"` + Avatar struct { + Ref struct { + Link string `json:"$link"` + } `json:"ref"` + } `json:"avatar"` + } `json:"value"` + } + if json.Unmarshal(recordBody, &record) == nil { + account.DisplayName = record.Value.DisplayName + account.Description = record.Value.Description + if record.Value.Avatar.Ref.Link != "" { + account.Avatar = pdsHost + "/xrpc/com.atproto.sync.getBlob?did=" + repo.DID + "&cid=" + record.Value.Avatar.Ref.Link + } + } + } + + accounts = append(accounts, PDSAccount{ + DID: account.DID, + Handle: account.Handle, + DisplayName: account.DisplayName, + Description: account.Description, + Avatar: account.Avatar, + }) + } + + // Render the page + tmpl := template.Must(template.New("accounts").Parse(accountsDirectoryHTML)) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl.Execute(w, map[string]interface{}{ + "Accounts": accounts, + "Count": len(accounts), + }) +} + +func (d *Dashboard) handleDashboard(w http.ResponseWriter, r *http.Request) { + stats, err := d.GetDashboardStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + funcMap := template.FuncMap{ + "pct": func(a, b int) float64 { + if b == 0 { + return 0 + } + return float64(a) * 100.0 / float64(b) + }, + "comma": func(n interface{}) string { + var val int + switch v := n.(type) { + case int: + val = v + case int32: + val = int(v) + case int64: + val = int(v) + default: + return "0" + } + if val < 0 { + return "-" + commaFormat(-val) + } + return commaFormat(val) + }, + } + + tmpl, err := template.New("dashboard").Funcs(funcMap).Parse(dashboardHTML) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/html") + tmpl.Execute(w, stats) +} + +func (d *Dashboard) handleAPIStats(w http.ResponseWriter, r *http.Request) { + stats, err := d.GetDashboardStats() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(stats) +} + +const accountsDirectoryHTML = ` + +
+Curated news feeds on Bluesky
+{{.Count}} feeds available
+No feeds available yet.
+ {{end}} +No feeds match your search.
+ + +