mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
clean up ui elements
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" id="user-dropdown-menu" hidden>
|
||||
<a href="/images" class="dropdown-item">Your Images</a>
|
||||
<a href="/u/{{ .User.Handle }}" class="dropdown-item">Your Repositories</a>
|
||||
<a href="/settings" class="dropdown-item">Settings</a>
|
||||
<hr class="dropdown-divider">
|
||||
<form action="/auth/logout" method="POST">
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
{{ define "repo-card" }}
|
||||
{{/*
|
||||
Repository card component - displays a repository as a clickable card
|
||||
|
||||
Expects: db.RepoCardData struct with fields:
|
||||
- OwnerHandle: string - Repository owner's handle
|
||||
- Repository: string - Repository name
|
||||
- Title: string (optional) - Display title
|
||||
- Description: string (optional) - Repository description
|
||||
- IconURL: string (optional) - Repository icon URL
|
||||
- StarCount: int - Number of stars
|
||||
- PullCount: int - Number of pulls
|
||||
*/}}
|
||||
<a href="/r/{{ .OwnerHandle }}/{{ .Repository }}" class="featured-card">
|
||||
<div class="featured-header">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="featured-icon">
|
||||
{{ else }}
|
||||
<div class="featured-icon-placeholder">{{ firstChar .Repository }}</div>
|
||||
{{ end }}
|
||||
<div class="featured-info">
|
||||
<div class="featured-title">
|
||||
<span class="featured-owner">{{ .OwnerHandle }}</span>
|
||||
<span class="featured-separator">/</span>
|
||||
<span class="featured-name">{{ .Repository }}</span>
|
||||
</div>
|
||||
{{ if .Description }}
|
||||
<p class="featured-description">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="featured-stats">
|
||||
<span class="featured-stat">
|
||||
<span class="star-icon">★</span>
|
||||
<span class="stat-count">{{ .StarCount }}</span>
|
||||
</span>
|
||||
<span class="featured-stat">
|
||||
<span class="pull-icon">↓</span>
|
||||
<span class="stat-count">{{ .PullCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
{{ end }}
|
||||
@@ -13,7 +13,20 @@
|
||||
|
||||
<main class="container">
|
||||
<div class="home-page">
|
||||
<h1>Recent Pushes</h1>
|
||||
<!-- Featured Repositories Section -->
|
||||
{{ if .FeaturedRepos }}
|
||||
<div class="featured-section">
|
||||
<h1>Featured</h1>
|
||||
<div class="featured-grid">
|
||||
{{ range .FeaturedRepos }}
|
||||
{{ template "repo-card" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Recent Pushes Section -->
|
||||
<h1>What's New</h1>
|
||||
|
||||
<div id="push-list" hx-get="/api/recent-pushes" hx-trigger="load" hx-swap="innerHTML">
|
||||
<!-- Initial loading state -->
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
{{ define "images" }}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Your Images - ATCR</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<script src="/static/js/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
{{ template "nav" . }}
|
||||
|
||||
<main class="container">
|
||||
<div class="images-page">
|
||||
<h1>Your Images</h1>
|
||||
|
||||
{{ if .Repositories }}
|
||||
{{ range .Repositories }}
|
||||
{{ $repoName := .Name }}
|
||||
<div class="repository-card">
|
||||
<div class="repo-header">
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ $repoName }}" class="repo-icon">
|
||||
{{ end }}
|
||||
<div class="repo-info">
|
||||
<div class="repo-title-row">
|
||||
<h2><a href="/r/{{ $.User.Handle }}/{{ $repoName }}" class="repo-title-link">{{ if .Title }}{{ .Title }}{{ else }}{{ $repoName }}{{ end }}</a></h2>
|
||||
{{ if .Licenses }}
|
||||
<span class="repo-badge license-badge">{{ .Licenses }}</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if .Description }}
|
||||
<p class="repo-description">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
<div class="repo-stats">
|
||||
<span>{{ .TagCount }} tags</span>
|
||||
<span>•</span>
|
||||
<span>{{ .ManifestCount }} manifests</span>
|
||||
<span>•</span>
|
||||
<time datetime="{{ .LastPush.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
Last push: {{ timeAgo .LastPush }}
|
||||
</time>
|
||||
{{ if .SourceURL }}
|
||||
<span>•</span>
|
||||
<a href="{{ .SourceURL }}" target="_blank" onclick="event.stopPropagation()" class="repo-link">Source</a>
|
||||
{{ end }}
|
||||
{{ if .DocumentationURL }}
|
||||
<span>•</span>
|
||||
<a href="{{ .DocumentationURL }}" target="_blank" onclick="event.stopPropagation()" class="repo-link">Docs</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="expand-btn" id="btn-{{ $repoName }}" onclick="toggleRepo('{{ $repoName }}'); event.stopPropagation();">▼</button>
|
||||
</div>
|
||||
|
||||
<div id="repo-{{ $repoName }}" class="repo-details" style="display: none;">
|
||||
<!-- Tags Section -->
|
||||
<div class="tags-section">
|
||||
<h3>Tags</h3>
|
||||
{{ if .Tags }}
|
||||
{{ range .Tags }}
|
||||
<div class="tag-row" id="tag-{{ $repoName }}-{{ .Tag }}">
|
||||
<span class="tag-name">{{ .Tag }}</span>
|
||||
<span class="tag-arrow">→</span>
|
||||
<code class="tag-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
|
||||
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
|
||||
<button class="delete-btn"
|
||||
hx-delete="/api/images/{{ $repoName }}/tags/{{ .Tag }}"
|
||||
hx-confirm="Delete tag {{ .Tag }}?"
|
||||
hx-target="#tag-{{ $repoName }}-{{ .Tag }}"
|
||||
hx-swap="outerHTML">
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p class="empty-message">No tags for this repository</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<!-- Manifests Section -->
|
||||
<div class="manifests-section">
|
||||
<h3>Manifests</h3>
|
||||
{{ if .Manifests }}
|
||||
{{ range .Manifests }}
|
||||
<div class="manifest-row" id="manifest-{{ .Digest }}">
|
||||
<code class="manifest-digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
|
||||
<span>{{ .HoldEndpoint }}</span>
|
||||
<time datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<p class="empty-message">No manifests for this repository</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="empty-state">
|
||||
<p>No images yet. Push your first image:</p>
|
||||
<pre><code>docker push {{ .RegistryURL }}/{{ .User.Handle }}/myapp:latest</code></pre>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal container for HTMX -->
|
||||
<div id="modal"></div>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
@@ -89,12 +89,23 @@
|
||||
{{ if .Repository.Tags }}
|
||||
<div class="tags-list">
|
||||
{{ range .Repository.Tags }}
|
||||
<div class="tag-item">
|
||||
<div class="tag-item" id="tag-{{ .Tag }}">
|
||||
<div class="tag-item-header">
|
||||
<span class="tag-name-large">{{ .Tag }}</span>
|
||||
<time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
<time class="tag-timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
{{ if $.IsOwner }}
|
||||
<button class="delete-btn"
|
||||
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag }}"
|
||||
hx-confirm="Delete tag {{ .Tag }}?"
|
||||
hx-target="#tag-{{ .Tag }}"
|
||||
hx-swap="outerHTML">
|
||||
🗑️
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tag-item-details">
|
||||
<code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
|
||||
|
||||
@@ -23,33 +23,12 @@
|
||||
<h1>{{ .ViewedUser.Handle }}</h1>
|
||||
</div>
|
||||
|
||||
{{ if .Pushes }}
|
||||
{{ range .Pushes }}
|
||||
<div class="push-card">
|
||||
<div class="push-header">
|
||||
<a href="/r/{{ $.ViewedUser.Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
|
||||
<span class="push-separator">:</span>
|
||||
<span class="push-tag">{{ .Tag }}</span>
|
||||
</div>
|
||||
|
||||
<div class="push-details">
|
||||
<code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
|
||||
<span class="separator">•</span>
|
||||
<span class="hold">{{ .HoldEndpoint }}</span>
|
||||
<span class="separator">•</span>
|
||||
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div class="push-command">
|
||||
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.ViewedUser.Handle }}/{{ .Repository }}:{{ .Tag }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.ViewedUser.Handle }}/{{ .Repository }}:{{ .Tag }}')">
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
{{ if .Repositories }}
|
||||
<div class="featured-grid">
|
||||
{{ range .Repositories }}
|
||||
{{ template "repo-card" . }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ else }}
|
||||
<div class="empty-state">
|
||||
<p>No images yet.</p>
|
||||
|
||||
@@ -1,29 +1,44 @@
|
||||
{{ range .Pushes }}
|
||||
<div class="push-card">
|
||||
<div class="push-header">
|
||||
<a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a>
|
||||
<span class="push-separator">/</span>
|
||||
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
|
||||
<span class="push-separator">:</span>
|
||||
<span class="push-tag">{{ .Tag }}</span>
|
||||
{{ if .IconURL }}
|
||||
<img src="{{ .IconURL }}" alt="{{ .Repository }}" class="push-icon">
|
||||
{{ else }}
|
||||
<div class="push-icon-placeholder">{{ firstChar .Repository }}</div>
|
||||
{{ end }}
|
||||
<div class="push-info">
|
||||
<div class="push-title-row">
|
||||
<div class="push-title">
|
||||
<a href="/u/{{ .Handle }}" class="push-user">{{ .Handle }}</a>
|
||||
<span class="push-separator">/</span>
|
||||
<a href="/r/{{ .Handle }}/{{ .Repository }}" class="push-repo">{{ .Repository }}</a>
|
||||
<span class="push-separator">:</span>
|
||||
<span class="push-tag">{{ .Tag }}</span>
|
||||
</div>
|
||||
<div class="push-stats">
|
||||
<span class="push-stat">
|
||||
<span class="star-icon">★</span>
|
||||
<span class="stat-count">{{ .StarCount }}</span>
|
||||
</span>
|
||||
<span class="push-stat">
|
||||
<span class="pull-icon">↓</span>
|
||||
<span class="stat-count">{{ .PullCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{{ if .Description }}
|
||||
<p class="push-description">{{ .Description }}</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="push-details">
|
||||
<code class="digest" title="{{ .Digest }}">{{ truncateDigest .Digest 12 }}</code>
|
||||
<span class="separator">•</span>
|
||||
<span class="hold">{{ .HoldEndpoint }}</span>
|
||||
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
<span class="separator">•</span>
|
||||
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
<div class="push-command">
|
||||
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ .Handle }}/{{ .Repository }}:{{ .Tag }}')">
|
||||
📋 Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user