···17171818# Generated assets (run go generate to rebuild)
1919pkg/appview/licenses/spdx-licenses.json
2020-pkg/appview/static/js/htmx.min.js
2121-pkg/appview/static/js/lucide.min.js
2020+pkg/appview/public/js/htmx.min.js
2121+pkg/appview/public/js/lucide.min.js
22222323# IDE
2424.zed/
···3131# OS
3232.DS_Store
3333Thumbs.db
3434+node_modules
+2-2
CLAUDE.md
···455455- `settings.go` - User settings management
456456- `api.go` - JSON API endpoints
457457458458-**Static Assets** (`pkg/appview/static/`, `pkg/appview/templates/`):
458458+**Static Assets** (`pkg/appview/public/`, `pkg/appview/templates/`):
459459- Templates use Go html/template
460460-- JavaScript in `static/js/app.js`
460460+- JavaScript in `public/js/app.js`
461461- Minimal CSS for clean UI
462462463463#### Hold Service (`cmd/hold/`)
···137137 IconURL string
138138 StarCount int
139139 PullCount int
140140- IsStarred bool // Whether the current user has starred this repository
141141- ArtifactType string // container-image, helm-chart, unknown
140140+ IsStarred bool // Whether the current user has starred this repository
141141+ ArtifactType string // container-image, helm-chart, unknown
142142+ Tag string // Latest tag name (e.g., "latest", "v1.0.0")
143143+ Digest string // Latest manifest digest (sha256:...)
144144+ LastUpdated time.Time // When the repository was last pushed to
142145}
143146144147// PlatformInfo represents platform information (OS/Architecture)
+182
pkg/appview/db/queries.go
···17361736 return featured, nil
17371737}
1738173817391739+// RepoCardSortOrder specifies how repo cards should be sorted
17401740+type RepoCardSortOrder string
17411741+17421742+const (
17431743+ // SortByScore sorts by combined stars and pulls (for Featured)
17441744+ SortByScore RepoCardSortOrder = "score"
17451745+ // SortByLastUpdate sorts by most recent push (for What's New)
17461746+ SortByLastUpdate RepoCardSortOrder = "last_update"
17471747+)
17481748+17491749+// GetRepoCards fetches repository cards with full data including Tag, Digest, and LastUpdated
17501750+func GetRepoCards(db *sql.DB, limit int, currentUserDID string, sortOrder RepoCardSortOrder) ([]RepoCardData, error) {
17511751+ // Build ORDER BY clause based on sort order
17521752+ var orderBy string
17531753+ switch sortOrder {
17541754+ case SortByLastUpdate:
17551755+ orderBy = "COALESCE(rs.last_push, m.created_at) DESC"
17561756+ default: // SortByScore
17571757+ orderBy = "repo_stats.score DESC, repo_stats.star_count DESC, repo_stats.pull_count DESC, m.created_at DESC"
17581758+ }
17591759+17601760+ query := `
17611761+ WITH latest_manifests AS (
17621762+ SELECT did, repository, MAX(id) as latest_id
17631763+ FROM manifests
17641764+ GROUP BY did, repository
17651765+ ),
17661766+ repo_stats AS (
17671767+ SELECT
17681768+ lm.did,
17691769+ lm.repository,
17701770+ COALESCE(rs.pull_count, 0) as pull_count,
17711771+ COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count,
17721772+ (COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) * 10) as score
17731773+ FROM latest_manifests lm
17741774+ LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository
17751775+ )
17761776+ SELECT
17771777+ m.did,
17781778+ u.handle,
17791779+ m.repository,
17801780+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
17811781+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
17821782+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
17831783+ repo_stats.star_count,
17841784+ repo_stats.pull_count,
17851785+ COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
17861786+ COALESCE(m.artifact_type, 'container-image'),
17871787+ COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
17881788+ COALESCE(m.digest, ''),
17891789+ COALESCE(rs.last_push, m.created_at),
17901790+ COALESCE(rp.avatar_cid, '')
17911791+ FROM latest_manifests lm
17921792+ JOIN manifests m ON lm.latest_id = m.id
17931793+ JOIN users u ON m.did = u.did
17941794+ JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
17951795+ LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
17961796+ LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
17971797+ ORDER BY ` + orderBy + `
17981798+ LIMIT ?
17991799+ `
18001800+18011801+ rows, err := db.Query(query, currentUserDID, limit)
18021802+ if err != nil {
18031803+ return nil, err
18041804+ }
18051805+ defer rows.Close()
18061806+18071807+ var cards []RepoCardData
18081808+ for rows.Next() {
18091809+ var c RepoCardData
18101810+ var ownerDID string
18111811+ var isStarredInt int
18121812+ var avatarCID string
18131813+ var lastUpdatedStr sql.NullString
18141814+18151815+ if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
18161816+ &c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
18171817+ return nil, err
18181818+ }
18191819+ c.IsStarred = isStarredInt > 0
18201820+ if lastUpdatedStr.Valid {
18211821+ if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
18221822+ c.LastUpdated = t
18231823+ }
18241824+ }
18251825+ // Prefer repo page avatar over annotation icon
18261826+ if avatarCID != "" {
18271827+ c.IconURL = BlobCDNURL(ownerDID, avatarCID)
18281828+ }
18291829+18301830+ cards = append(cards, c)
18311831+ }
18321832+18331833+ if err := rows.Err(); err != nil {
18341834+ return nil, err
18351835+ }
18361836+18371837+ return cards, nil
18381838+}
18391839+18401840+// GetUserRepoCards fetches repository cards for a specific user with full data
18411841+func GetUserRepoCards(db *sql.DB, userDID string, currentUserDID string) ([]RepoCardData, error) {
18421842+ query := `
18431843+ WITH latest_manifests AS (
18441844+ SELECT did, repository, MAX(id) as latest_id
18451845+ FROM manifests
18461846+ WHERE did = ?
18471847+ GROUP BY did, repository
18481848+ ),
18491849+ repo_stats AS (
18501850+ SELECT
18511851+ lm.did,
18521852+ lm.repository,
18531853+ COALESCE(rs.pull_count, 0) as pull_count,
18541854+ COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count
18551855+ FROM latest_manifests lm
18561856+ LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository
18571857+ )
18581858+ SELECT
18591859+ m.did,
18601860+ u.handle,
18611861+ m.repository,
18621862+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
18631863+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
18641864+ COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
18651865+ repo_stats.star_count,
18661866+ repo_stats.pull_count,
18671867+ COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
18681868+ COALESCE(m.artifact_type, 'container-image'),
18691869+ COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
18701870+ COALESCE(m.digest, ''),
18711871+ COALESCE(rs.last_push, m.created_at),
18721872+ COALESCE(rp.avatar_cid, '')
18731873+ FROM latest_manifests lm
18741874+ JOIN manifests m ON lm.latest_id = m.id
18751875+ JOIN users u ON m.did = u.did
18761876+ JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
18771877+ LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
18781878+ LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
18791879+ ORDER BY COALESCE(rs.last_push, m.created_at) DESC
18801880+ `
18811881+18821882+ rows, err := db.Query(query, userDID, currentUserDID)
18831883+ if err != nil {
18841884+ return nil, err
18851885+ }
18861886+ defer rows.Close()
18871887+18881888+ var cards []RepoCardData
18891889+ for rows.Next() {
18901890+ var c RepoCardData
18911891+ var ownerDID string
18921892+ var isStarredInt int
18931893+ var avatarCID string
18941894+ var lastUpdatedStr sql.NullString
18951895+18961896+ if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.Repository, &c.Title, &c.Description, &c.IconURL,
18971897+ &c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
18981898+ return nil, err
18991899+ }
19001900+ c.IsStarred = isStarredInt > 0
19011901+ if lastUpdatedStr.Valid {
19021902+ if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
19031903+ c.LastUpdated = t
19041904+ }
19051905+ }
19061906+ // Prefer repo page avatar over annotation icon
19071907+ if avatarCID != "" {
19081908+ c.IconURL = BlobCDNURL(ownerDID, avatarCID)
19091909+ }
19101910+19111911+ cards = append(cards, c)
19121912+ }
19131913+19141914+ if err := rows.Err(); err != nil {
19151915+ return nil, err
19161916+ }
19171917+19181918+ return cards, nil
19191919+}
19201920+17391921// RepoPage represents a repository page record cached from PDS
17401922type RepoPage struct {
17411923 DID string
+57-2
pkg/appview/handlers/api.go
···11package handlers
2233import (
44+ "bytes"
45 "database/sql"
56 "errors"
67 "fmt"
88+ "html/template"
79 "log/slog"
810 "net/http"
911···2123 DB *sql.DB
2224 Directory identity.Directory
2325 Refresher *oauth.Refresher
2626+ Templates *template.Template
2427}
25282629func (h *StarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···6467 return
6568 }
66696767- // Return success
7070+ // Check if HTMX request - return HTML component
7171+ if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
7272+ // Get current star count and do optimistic increment
7373+ stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
7474+ starCount := 0
7575+ if stats != nil {
7676+ starCount = stats.StarCount
7777+ }
7878+ starCount++ // Optimistic increment
7979+8080+ renderStarComponent(w, h.Templates, handle, repository, true, starCount)
8181+ return
8282+ }
8383+8484+ // Return JSON for API clients
6885 w.WriteHeader(http.StatusCreated)
6986 render.JSON(w, r, map[string]bool{"starred": true})
7087}
···7491 DB *sql.DB
7592 Directory identity.Directory
7693 Refresher *oauth.Refresher
9494+ Templates *template.Template
7795}
78967997func (h *UnstarRepositoryHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
···119137 slog.Debug("Star record not found, already unstarred")
120138 }
121139122122- // Return success
140140+ // Check if HTMX request - return HTML component
141141+ if r.Header.Get("HX-Request") == "true" && h.Templates != nil {
142142+ // Get current star count and do optimistic decrement
143143+ stats, _ := db.GetRepositoryStats(h.DB, ownerDID, repository)
144144+ starCount := 0
145145+ if stats != nil {
146146+ starCount = stats.StarCount
147147+ }
148148+ if starCount > 0 {
149149+ starCount-- // Optimistic decrement
150150+ }
151151+152152+ renderStarComponent(w, h.Templates, handle, repository, false, starCount)
153153+ return
154154+ }
155155+156156+ // Return JSON for API clients
123157 render.JSON(w, r, map[string]bool{"starred": false})
124158}
125159···264298 w.Header().Set("Cache-Control", "public, max-age=300") // Cache for 5 minutes
265299 render.JSON(w, r, response)
266300}
301301+302302+// renderStarComponent renders the star component HTML for HTMX responses
303303+func renderStarComponent(w http.ResponseWriter, tmpl *template.Template, handle, repository string, isStarred bool, starCount int) {
304304+ data := map[string]any{
305305+ "Interactive": true,
306306+ "Handle": handle,
307307+ "Repository": repository,
308308+ "IsStarred": isStarred,
309309+ "StarCount": starCount,
310310+ }
311311+312312+ var buf bytes.Buffer
313313+ if err := tmpl.ExecuteTemplate(&buf, "star", data); err != nil {
314314+ slog.Error("Failed to render star component", "error", err)
315315+ http.Error(w, "Failed to render component", http.StatusInternalServerError)
316316+ return
317317+ }
318318+319319+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
320320+ _, _ = w.Write(buf.Bytes())
321321+}
+28-19
pkg/appview/handlers/home.go
···66import (
77 "database/sql"
88 "html/template"
99+ "log"
910 "net/http"
1011 "strconv"
1112···1415 "atcr.io/pkg/appview/middleware"
1516)
16171818+// BenefitCard represents a feature benefit card on the home page
1919+type BenefitCard struct {
2020+ Icon string
2121+ Title string
2222+ Description string
2323+}
2424+1725// HomeHandler handles the home page
1826type HomeHandler struct {
1927 DB *sql.DB
···2836 currentUserDID = user.DID
2937 }
30383131- // Fetch featured repositories (top 6)
3232- featured, err := db.GetFeaturedRepositories(h.DB, 6, currentUserDID)
3939+ // Fetch featured repositories (top 6 by score - carousel cycles through them)
4040+ featuredCards, err := db.GetRepoCards(h.DB, 6, currentUserDID, db.SortByScore)
3341 if err != nil {
3434- // Log error but continue - featured section will be empty
3535- featured = []db.FeaturedRepository{}
4242+ log.Printf("Error fetching featured repos: %v", err)
4343+ featuredCards = []db.RepoCardData{}
3644 }
37453838- // Convert to RepoCardData for template
3939- cards := make([]db.RepoCardData, len(featured))
4040- for i, repo := range featured {
4141- cards[i] = db.RepoCardData{
4242- OwnerHandle: repo.OwnerHandle,
4343- Repository: repo.Repository,
4444- Title: repo.Title,
4545- Description: repo.Description,
4646- IconURL: repo.IconURL,
4747- StarCount: repo.StarCount,
4848- PullCount: repo.PullCount,
4949- IsStarred: repo.IsStarred,
5050- ArtifactType: repo.ArtifactType,
5151- }
4646+ // Fetch recently updated repositories (top 18 by last push - 6 rows)
4747+ recentCards, err := db.GetRepoCards(h.DB, 18, currentUserDID, db.SortByLastUpdate)
4848+ if err != nil {
4949+ log.Printf("Error fetching recent repos: %v", err)
5050+ recentCards = []db.RepoCardData{}
5151+ }
5252+5353+ benefits := []BenefitCard{
5454+ {Icon: "ship", Title: "Works with Docker", Description: "Use docker push & pull. No new tools to learn."},
5555+ {Icon: "anchor", Title: "Your Data", Description: "Join shared holds or captain your own storage."},
5656+ {Icon: "compass", Title: "Discover Images", Description: "Browse and star public container registries."},
5257 }
53585459 data := struct {
5560 PageData
5661 FeaturedRepos []db.RepoCardData
6262+ RecentRepos []db.RepoCardData
6363+ Benefits []BenefitCard
5764 }{
5865 PageData: NewPageData(r, h.RegistryURL),
5959- FeaturedRepos: cards,
6666+ FeaturedRepos: featuredCards,
6767+ RecentRepos: recentCards,
6868+ Benefits: benefits,
6069 }
61706271 if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil {
+2
pkg/appview/handlers/repository.go
···245245 Tags []db.TagWithPlatforms // Tags with platform info
246246 Manifests []db.ManifestWithMetadata // Top-level manifests only
247247 StarCount int
248248+ PullCount int
248249 IsStarred bool
249250 IsOwner bool // Whether current user owns this repository
250251 ReadmeHTML template.HTML
···256257 Tags: tagsWithPlatforms,
257258 Manifests: manifests,
258259 StarCount: stats.StarCount,
260260+ PullCount: stats.PullCount,
259261 IsStarred: isStarred,
260262 IsOwner: isOwner,
261263 ReadmeHTML: readmeHTML,
+11-25
pkg/appview/handlers/user.go
···33import (
44 "database/sql"
55 "html/template"
66+ "log"
67 "net/http"
7889 "atcr.io/pkg/appview/db"
1010+ "atcr.io/pkg/appview/middleware"
911 "atcr.io/pkg/atproto"
1012 "github.com/go-chi/chi/v5"
1113)
···5052 viewedUser.Handle = resolvedHandle
5153 }
52545353- // Fetch repositories for this user
5454- repos, err := db.GetUserRepositories(h.DB, viewedUser.DID)
5555- if err != nil {
5656- http.Error(w, err.Error(), http.StatusInternalServerError)
5757- return
5555+ // Get current user DID for star state (empty string if not logged in)
5656+ var currentUserDID string
5757+ if user := middleware.GetUser(r); user != nil {
5858+ currentUserDID = user.DID
5859 }
59606060- // Convert to RepoCardData for template
6161- cards := make([]db.RepoCardData, 0, len(repos))
6262- for _, repo := range repos {
6363- stats, err := db.GetRepositoryStats(h.DB, viewedUser.DID, repo.Name)
6464- if err != nil {
6565- // Continue with zero stats on error
6666- stats = &db.RepositoryStats{
6767- DID: viewedUser.DID,
6868- Repository: repo.Name,
6969- }
7070- }
7171- cards = append(cards, db.RepoCardData{
7272- OwnerHandle: viewedUser.Handle,
7373- Repository: repo.Name,
7474- Title: repo.Title,
7575- Description: repo.Description,
7676- IconURL: repo.IconURL,
7777- StarCount: stats.StarCount,
7878- PullCount: stats.PullCount,
7979- })
6161+ // Fetch repository cards for this user
6262+ cards, err := db.GetUserRepoCards(h.DB, viewedUser.DID, currentUserDID)
6363+ if err != nil {
6464+ log.Printf("Error fetching repo cards for user %s: %v", viewedUser.DID, err)
6565+ cards = []db.RepoCardData{}
8066 }
81678268 data := struct {