clean up ui elements

This commit is contained in:
Evan Jarrett
2025-10-08 20:50:27 -05:00
parent 7fce7edcb1
commit 3add9d3d3b
15 changed files with 561 additions and 267 deletions
-7
View File
@@ -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,
+40 -7
View File
@@ -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
}
+96 -4
View File
@@ -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
}
+24 -1
View File
@@ -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 {
-36
View File
@@ -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
+8
View File
@@ -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 {
+29 -7
View File
@@ -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 {
+256 -39
View File
@@ -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);
}
}
+1 -1
View File
@@ -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 }}
+14 -1
View File
@@ -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 -->
-119
View File
@@ -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 }}
+15 -4
View File
@@ -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>
+5 -26
View File
@@ -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>
+30 -15
View File
@@ -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 }}