diff --git a/cmd/appview/serve.go b/cmd/appview/serve.go index 8c5a7df..b93fe16 100644 --- a/cmd/appview/serve.go +++ b/cmd/appview/serve.go @@ -197,7 +197,6 @@ func serveRegistry(cmd *cobra.Command, args []string) error { fmt.Printf("UI enabled:\n") fmt.Printf(" - Home: /\n") - fmt.Printf(" - Images: /images\n") fmt.Printf(" - Settings: /settings\n") } @@ -550,12 +549,6 @@ func initializeUIRoutes(database *sql.DB, readOnlyDB *sql.DB, sessionStore *db.S authRouter := router.NewRoute().Subrouter() authRouter.Use(appmiddleware.RequireAuth(sessionStore, database)) - authRouter.Handle("/images", &uihandlers.ImagesHandler{ - DB: readOnlyDB, // Read-only: just displays user's images - Templates: templates, - RegistryURL: uihandlers.TrimRegistryURL(baseURL), - }).Methods("GET") - authRouter.Handle("/settings", &uihandlers.SettingsHandler{ Templates: templates, Refresher: refresher, diff --git a/pkg/appview/db/models.go b/pkg/appview/db/models.go index 148d305..d7c87f7 100644 --- a/pkg/appview/db/models.go +++ b/pkg/appview/db/models.go @@ -52,13 +52,17 @@ type Tag struct { // Push represents a combined tag and manifest for the recent pushes view type Push struct { - DID string - Handle string - Repository string - Tag string - Digest string - HoldEndpoint string - CreatedAt time.Time + DID string + Handle string + Repository string + Tag string + Digest string + Title string + Description string + IconURL string + StarCount int + PullCount int + CreatedAt time.Time } // Repository represents an aggregated view of a user's repository @@ -87,3 +91,32 @@ type RepositoryStats struct { PushCount int `json:"push_count"` LastPush *time.Time `json:"last_push,omitempty"` } + +// FeaturedRepository represents a repository in the featured section +type FeaturedRepository struct { + OwnerDID string + OwnerHandle string + Repository string + Title string + Description string + IconURL string + StarCount int + PullCount int +} + +// RepositoryWithStats combines repository data with statistics +type RepositoryWithStats struct { + Repository + Stats RepositoryStats +} + +// RepoCardData contains all data needed to render a repository card +type RepoCardData struct { + OwnerHandle string + Repository string + Title string + Description string + IconURL string + StarCount int + PullCount int +} diff --git a/pkg/appview/db/queries.go b/pkg/appview/db/queries.go index e4dbddb..561ec8a 100644 --- a/pkg/appview/db/queries.go +++ b/pkg/appview/db/queries.go @@ -33,10 +33,22 @@ func escapeLikePattern(s string) string { // GetRecentPushes fetches recent pushes with pagination func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) { query := ` - SELECT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at + SELECT + u.did, + u.handle, + t.repository, + t.tag, + t.digest, + COALESCE(m.title, ''), + COALESCE(m.description, ''), + COALESCE(m.icon_url, ''), + COALESCE(rs.pull_count, 0), + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), + t.created_at FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository ` args := []any{} @@ -58,7 +70,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, var pushes []Push for rows.Next() { var p Push - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { return nil, 0, err } pushes = append(pushes, p) @@ -90,10 +102,22 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err searchPattern := "%" + query + "%" sqlQuery := ` - SELECT DISTINCT u.did, u.handle, t.repository, t.tag, t.digest, m.hold_endpoint, t.created_at + SELECT DISTINCT + u.did, + u.handle, + t.repository, + t.tag, + t.digest, + COALESCE(m.title, ''), + COALESCE(m.description, ''), + COALESCE(m.icon_url, ''), + COALESCE(rs.pull_count, 0), + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0), + t.created_at FROM tags t JOIN users u ON t.did = u.did JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest + LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository WHERE u.handle LIKE ? ESCAPE '\' OR u.did = ? OR t.repository LIKE ? ESCAPE '\' @@ -112,7 +136,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err var pushes []Push for rows.Next() { var p Push - if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.HoldEndpoint, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt); err != nil { return nil, 0, err } pushes = append(pushes, p) @@ -1086,3 +1110,71 @@ func (m *MetricsDB) IncrementPullCount(did, repository string) error { func (m *MetricsDB) IncrementPushCount(did, repository string) error { return IncrementPushCount(m.db, did, repository) } + +// GetFeaturedRepositories fetches top repositories sorted by stars and pulls +func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error) { + query := ` + WITH latest_manifests AS ( + SELECT did, repository, MAX(id) as latest_id + FROM manifests + GROUP BY did, repository + ), + repo_stats AS ( + SELECT + lm.did, + lm.repository, + COALESCE(rs.pull_count, 0) as pull_count, + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) as star_count, + (COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = lm.did AND repository = lm.repository), 0) * 10) as score + FROM latest_manifests lm + LEFT JOIN repository_stats rs ON lm.did = rs.did AND lm.repository = rs.repository + ) + SELECT + m.did, + u.handle, + m.repository, + m.title, + m.description, + m.icon_url, + rs.pull_count, + rs.star_count + FROM latest_manifests lm + JOIN manifests m ON lm.latest_id = m.id + JOIN users u ON m.did = u.did + JOIN repo_stats rs ON m.did = rs.did AND m.repository = rs.repository + ORDER BY rs.score DESC, rs.star_count DESC, rs.pull_count DESC, m.created_at DESC + LIMIT ? + ` + + rows, err := db.Query(query, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var featured []FeaturedRepository + for rows.Next() { + var f FeaturedRepository + var title, description, iconURL sql.NullString + + if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository, + &title, &description, &iconURL, &f.PullCount, &f.StarCount); err != nil { + return nil, err + } + + // Convert NullString to string + if title.Valid { + f.Title = title.String + } + if description.Valid { + f.Description = description.String + } + if iconURL.Valid { + f.IconURL = iconURL.String + } + + featured = append(featured, f) + } + + return featured, nil +} diff --git a/pkg/appview/handlers/home.go b/pkg/appview/handlers/home.go index b75416f..331839c 100644 --- a/pkg/appview/handlers/home.go +++ b/pkg/appview/handlers/home.go @@ -17,10 +17,33 @@ type HomeHandler struct { } func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Fetch featured repositories (top 6) + featured, err := db.GetFeaturedRepositories(h.DB, 6) + if err != nil { + // Log error but continue - featured section will be empty + featured = []db.FeaturedRepository{} + } + + // Convert to RepoCardData for template + cards := make([]db.RepoCardData, len(featured)) + for i, repo := range featured { + cards[i] = db.RepoCardData{ + OwnerHandle: repo.OwnerHandle, + Repository: repo.Repository, + Title: repo.Title, + Description: repo.Description, + IconURL: repo.IconURL, + StarCount: repo.StarCount, + PullCount: repo.PullCount, + } + } + data := struct { PageData + FeaturedRepos []db.RepoCardData }{ - PageData: NewPageData(r, h.RegistryURL), + PageData: NewPageData(r, h.RegistryURL), + FeaturedRepos: cards, } if err := h.Templates.ExecuteTemplate(w, "home", data); err != nil { diff --git a/pkg/appview/handlers/images.go b/pkg/appview/handlers/images.go index cd48713..632b671 100644 --- a/pkg/appview/handlers/images.go +++ b/pkg/appview/handlers/images.go @@ -2,7 +2,6 @@ package handlers import ( "database/sql" - "html/template" "net/http" "atcr.io/pkg/appview/db" @@ -10,41 +9,6 @@ import ( "github.com/gorilla/mux" ) -// ImagesHandler handles the images management page -type ImagesHandler struct { - DB *sql.DB - Templates *template.Template - RegistryURL string -} - -func (h *ImagesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - user := middleware.GetUser(r) - if user == nil { - http.Redirect(w, r, "/auth/oauth/login?return_to=/ui/images", http.StatusFound) - return - } - - // Fetch repositories from database (cached firehose data) - repos, err := db.GetUserRepositories(h.DB, user.DID) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - data := struct { - PageData - Repositories []db.Repository - }{ - PageData: NewPageData(r, h.RegistryURL), - Repositories: repos, - } - - if err := h.Templates.ExecuteTemplate(w, "images", data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - // DeleteTagHandler handles deleting a tag type DeleteTagHandler struct { DB *sql.DB diff --git a/pkg/appview/handlers/repository.go b/pkg/appview/handlers/repository.go index b06b51a..8a23fef 100644 --- a/pkg/appview/handlers/repository.go +++ b/pkg/appview/handlers/repository.go @@ -78,18 +78,26 @@ func (h *RepositoryPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request } } + // Check if current user is the repository owner + isOwner := false + if user != nil { + isOwner = (user.DID == owner.DID) + } + data := struct { PageData Owner *db.User // Repository owner Repository *db.Repository StarCount int IsStarred bool + IsOwner bool // Whether current user owns this repository }{ PageData: NewPageData(r, h.RegistryURL), Owner: owner, Repository: repo, StarCount: stats.StarCount, IsStarred: isStarred, + IsOwner: isOwner, } if err := h.Templates.ExecuteTemplate(w, "repository", data); err != nil { diff --git a/pkg/appview/handlers/user.go b/pkg/appview/handlers/user.go index e502701..328c4fb 100644 --- a/pkg/appview/handlers/user.go +++ b/pkg/appview/handlers/user.go @@ -32,21 +32,43 @@ func (h *UserPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } - // Fetch pushes for this user (limit to 100 for now) - pushes, _, err := db.GetRecentPushes(h.DB, 100, 0, viewedUser.Handle) + // Fetch repositories for this user + repos, err := db.GetUserRepositories(h.DB, viewedUser.DID) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + // Convert to RepoCardData for template + cards := make([]db.RepoCardData, 0, len(repos)) + for _, repo := range repos { + stats, err := db.GetRepositoryStats(h.DB, viewedUser.DID, repo.Name) + if err != nil { + // Continue with zero stats on error + stats = &db.RepositoryStats{ + DID: viewedUser.DID, + Repository: repo.Name, + } + } + cards = append(cards, db.RepoCardData{ + OwnerHandle: viewedUser.Handle, + Repository: repo.Name, + Title: repo.Title, + Description: repo.Description, + IconURL: repo.IconURL, + StarCount: stats.StarCount, + PullCount: stats.PullCount, + }) + } + data := struct { PageData - ViewedUser *db.User // User whose page we're viewing - Pushes []db.Push + ViewedUser *db.User // User whose page we're viewing + Repositories []db.RepoCardData }{ - PageData: NewPageData(r, h.RegistryURL), - ViewedUser: viewedUser, - Pushes: pushes, + PageData: NewPageData(r, h.RegistryURL), + ViewedUser: viewedUser, + Repositories: cards, } if err := h.Templates.ExecuteTemplate(w, "user", data); err != nil { diff --git a/pkg/appview/static/css/style.css b/pkg/appview/static/css/style.css index 3ef61ff..4bc6ca8 100644 --- a/pkg/appview/static/css/style.css +++ b/pkg/appview/static/css/style.css @@ -5,9 +5,11 @@ --danger: #dc3545; --bg: #ffffff; --fg: #1a1a1a; + --border-dark: #666; --border: #e0e0e0; --code-bg: #f5f5f5; --hover-bg: #f9f9f9; + --star: #fbbf24; } * { @@ -142,7 +144,7 @@ body { font-weight: bold; font-size: 2rem; text-transform: uppercase; - color: white; + color: var(--bg); } .user-profile { @@ -158,7 +160,7 @@ body { } .user-handle { - color: white; + color: var(--bg); font-size: 0.95rem; } @@ -272,8 +274,10 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .push-header { - font-size: 1.1rem; - margin-bottom: 0.5rem; + display: flex; + gap: 1rem; + align-items: flex-start; + margin-bottom: 0.75rem; } .push-user { @@ -287,13 +291,13 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .push-separator { - color: #999; + color: var(--border-dark); margin: 0 0.25rem; } .push-repo { font-weight: 500; - color: var(--fg); + color: var(--primary); text-decoration: none; } @@ -307,12 +311,9 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .push-details { - display: flex; - gap: 0.5rem; - align-items: center; - color: #666; + color: var(--border-dark); font-size: 0.9rem; - margin-bottom: 0.5rem; + margin-bottom: 0.75rem; } .digest { @@ -324,23 +325,87 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .separator { - color: #ccc; + color: var(--border); } -.push-command { +/* Push card icon and layout */ +.push-icon { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.push-icon-placeholder { + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--primary); display: flex; - gap: 0.5rem; align-items: center; - margin-top: 0.5rem; - padding: 0.5rem; - background: var(--code-bg); - border-radius: 4px; + justify-content: center; + font-weight: bold; + font-size: 1.5rem; + text-transform: uppercase; + color: var(--bg); + flex-shrink: 0; } -.pull-command { +.push-info { flex: 1; - font-family: 'Monaco', 'Courier New', monospace; + min-width: 0; +} + +.push-title-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + margin-bottom: 0.25rem; +} + +.push-title { + font-size: 1.1rem; + flex: 1; +} + +.push-description { + color: var(--border-dark); font-size: 0.9rem; + line-height: 1.4; + margin: 0.25rem 0 0 0; +} + +/* Push stats */ +.push-stats { + display: flex; + gap: 1rem; + align-items: center; + flex-shrink: 0; +} + +.push-stat { + display: flex; + align-items: center; + gap: 0.35rem; + color: var(--border-dark); + font-size: 0.9rem; +} + +.push-stat .star-icon { + color: var(--star); + font-size: 1rem; +} + +.push-stat .pull-icon { + color: var(--primary); + font-size: 1rem; +} + +.push-stat .stat-count { + font-weight: 600; + color: var(--fg); } /* Repository Cards */ @@ -356,7 +421,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .repo-header:hover { - background: #f0f0f0; + background: var(--hover-bg); } .repo-icon { @@ -405,20 +470,20 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .license-badge { - background: #e3f2fd; - color: #1976d2; + background: var(--code-bg); + color: var(--primary); border: 1px solid #90caf9; } .repo-description { - color: #555; + color: var(--border-dark); font-size: 0.95rem; margin: 0.25rem 0 0.5rem 0; line-height: 1.4; } .repo-stats { - color: #666; + color:var(--border-dark); font-size: 0.9rem; display: flex; gap: 0.5rem; @@ -475,7 +540,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .tag-arrow { - color: #999; + color: var(--border-dark); } .tag-digest, .manifest-digest { @@ -531,7 +596,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { .form-group small { display: block; margin-top: 0.25rem; - color: #666; + color: var(--border-dark); font-size: 0.85rem; } @@ -560,7 +625,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .modal-content { - background: white; + background: var(--bg); padding: 2rem; border-radius: 8px; max-width: 800px; @@ -599,7 +664,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { .loading { text-align: center; padding: 2rem; - color: #666; + color: var(--border-dark); } .empty-state { @@ -624,7 +689,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .empty-message { - color: #999; + color: var(--border-dark); font-style: italic; padding: 1rem; } @@ -768,7 +833,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { font-weight: bold; font-size: 2.5rem; text-transform: uppercase; - color: white; + color: var(--bg); flex-shrink: 0; } @@ -791,7 +856,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .repo-separator { - color: #999; + color: var(--border-dark); margin: 0 0.25rem; } @@ -800,7 +865,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .repo-hero-description { - color: #555; + color: var(--border-dark); font-size: 1.1rem; line-height: 1.5; margin: 0.5rem 0 0 0; @@ -835,19 +900,19 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .star-btn.starred { - border-color: #fbbf24; - background: #fffbeb; + border-color:var(--star); + background: var(--code-bg); } .star-btn.starred:hover:not(:disabled) { - background: #fef3c7; + background: var(--hover-bg); } .star-icon { font-size: 1.25rem; line-height: 1; transition: transform 0.2s ease; - color: #fbbf24; + color:var(--star); } .star-btn:hover:not(:disabled) .star-icon { @@ -943,7 +1008,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { } .tag-timestamp { - color: #666; + color: var(--border-dark); font-size: 0.9rem; } @@ -955,7 +1020,7 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { display: flex; gap: 0.5rem; align-items: center; - color: #666; + color: var(--border-dark); font-size: 0.9rem; margin-top: 0.5rem; } @@ -965,6 +1030,143 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { color: var(--secondary); } +/* Featured Repositories Section */ +.featured-section { + margin-bottom: 3rem; +} + +.featured-section h1 { + font-size: 1.8rem; + margin-bottom: 1.5rem; +} + +.featured-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.featured-card { + border: 1px solid var(--border); + border-radius: 8px; + padding: 1.5rem; + background: var(--bg); + box-shadow: 0 1px 3px rgba(0,0,0,0.05); + transition: all 0.2s ease; + text-decoration: none; + color: var(--fg); + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 180px; +} + +.featured-card:hover { + box-shadow: 0 4px 8px rgba(0,0,0,0.1); + border-color: var(--primary); + transform: translateY(-2px); +} + +.featured-header { + display: flex; + gap: 1rem; + align-items: flex-start; + margin-bottom: 1rem; +} + +.featured-icon { + width: 48px; + height: 48px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.featured-icon-placeholder { + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 1.5rem; + text-transform: uppercase; + color:var(--bg); + flex-shrink: 0; +} + +.featured-info { + flex: 1; + min-width: 0; +} + +.featured-title { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 0.5rem; + line-height: 1.3; +} + +.featured-owner { + color: var(--primary); +} + +.featured-separator { + color: var(--border-dark); + margin: 0 0.25rem; +} + +.featured-name { + color: var(--fg); +} + +.featured-description { + color: var(--border-dark); + font-size: 0.9rem; + line-height: 1.4; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + line-clamp: 2; +} + +.featured-stats { + display: flex; + gap: 1.5rem; + align-items: center; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.featured-stat { + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--border-dark); + font-size: 0.95rem; +} + +.featured-stat .star-icon { + color: var(--star); + font-size: 1.1rem; +} + +.featured-stat .pull-icon { + color: var(--primary); + font-size: 1.1rem; +} + +.featured-stat .stat-count { + font-weight: 600; + color: var(--fg); +} + /* Responsive */ @media (max-width: 768px) { .navbar { @@ -1008,4 +1210,19 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover { flex-direction: column; align-items: flex-start; } + + .featured-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .featured-card { + min-height: auto; + } +} + +@media (max-width: 1024px) and (min-width: 769px) { + .featured-grid { + grid-template-columns: repeat(2, 1fr); + } } diff --git a/pkg/appview/templates/components/nav.html b/pkg/appview/templates/components/nav.html index 99f3718..76994fe 100644 --- a/pkg/appview/templates/components/nav.html +++ b/pkg/appview/templates/components/nav.html @@ -25,7 +25,7 @@