mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +00:00
lucide icon pack. clean up some templates/css
This commit is contained in:
@@ -70,6 +70,7 @@ type Push struct {
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
IsStarred bool // Whether the current user has starred this repository
|
||||
CreatedAt time.Time
|
||||
HoldEndpoint string // Hold endpoint for health checking
|
||||
Reachable bool // Whether the hold endpoint is reachable
|
||||
@@ -114,6 +115,7 @@ type FeaturedRepository struct {
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
IsStarred bool // Whether the current user has starred this repository
|
||||
}
|
||||
|
||||
// RepositoryWithStats combines repository data with statistics
|
||||
@@ -131,6 +133,7 @@ type RepoCardData struct {
|
||||
IconURL string
|
||||
StarCount int
|
||||
PullCount int
|
||||
IsStarred bool // Whether the current user has starred this repository
|
||||
}
|
||||
|
||||
// PlatformInfo represents platform information (OS/Architecture)
|
||||
|
||||
+19
-10
@@ -31,7 +31,7 @@ func escapeLikePattern(s string) string {
|
||||
}
|
||||
|
||||
// GetRecentPushes fetches recent pushes with pagination
|
||||
func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) {
|
||||
func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUserDID string) ([]Push, int, error) {
|
||||
query := `
|
||||
SELECT
|
||||
u.did,
|
||||
@@ -44,6 +44,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
|
||||
COALESCE(rs.pull_count, 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
|
||||
t.created_at,
|
||||
m.hold_endpoint
|
||||
FROM tags t
|
||||
@@ -52,7 +53,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
|
||||
`
|
||||
|
||||
args := []any{}
|
||||
args := []any{currentUserDID}
|
||||
|
||||
if userFilter != "" {
|
||||
query += " WHERE u.handle = ? OR u.did = ?"
|
||||
@@ -71,9 +72,11 @@ 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.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
var isStarredInt int
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
p.IsStarred = isStarredInt > 0
|
||||
pushes = append(pushes, p)
|
||||
}
|
||||
|
||||
@@ -95,7 +98,7 @@ func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push,
|
||||
}
|
||||
|
||||
// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations
|
||||
func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) {
|
||||
func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID string) ([]Push, int, error) {
|
||||
// Escape LIKE wildcards so they're treated literally
|
||||
query = escapeLikePattern(query)
|
||||
|
||||
@@ -114,6 +117,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
|
||||
COALESCE(rs.pull_count, 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
|
||||
t.created_at,
|
||||
m.hold_endpoint
|
||||
FROM tags t
|
||||
@@ -132,7 +136,7 @@ func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, err
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset)
|
||||
rows, err := db.Query(sqlQuery, currentUserDID, searchPattern, query, searchPattern, searchPattern, limit, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -141,9 +145,11 @@ 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.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
var isStarredInt int
|
||||
if err := rows.Scan(&p.DID, &p.Handle, &p.Repository, &p.Tag, &p.Digest, &p.Title, &p.Description, &p.IconURL, &p.PullCount, &p.StarCount, &isStarredInt, &p.CreatedAt, &p.HoldEndpoint); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
p.IsStarred = isStarredInt > 0
|
||||
pushes = append(pushes, p)
|
||||
}
|
||||
|
||||
@@ -1571,7 +1577,7 @@ func (m *MetricsDB) IncrementPushCount(did, repository string) error {
|
||||
}
|
||||
|
||||
// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
|
||||
func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error) {
|
||||
func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) {
|
||||
query := `
|
||||
WITH latest_manifests AS (
|
||||
SELECT did, repository, MAX(id) as latest_id
|
||||
@@ -1596,7 +1602,8 @@ func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
|
||||
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
|
||||
rs.pull_count,
|
||||
rs.star_count
|
||||
rs.star_count,
|
||||
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0)
|
||||
FROM latest_manifests lm
|
||||
JOIN manifests m ON lm.latest_id = m.id
|
||||
JOIN users u ON m.did = u.did
|
||||
@@ -1605,7 +1612,7 @@ func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error
|
||||
LIMIT ?
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, limit)
|
||||
rows, err := db.Query(query, currentUserDID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1614,11 +1621,13 @@ func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error
|
||||
var featured []FeaturedRepository
|
||||
for rows.Next() {
|
||||
var f FeaturedRepository
|
||||
var isStarredInt int
|
||||
|
||||
if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository,
|
||||
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil {
|
||||
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.IsStarred = isStarredInt > 0
|
||||
|
||||
featured = append(featured, f)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/holdhealth"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
)
|
||||
|
||||
// HomeHandler handles the home page
|
||||
@@ -21,8 +22,14 @@ type HomeHandler struct {
|
||||
}
|
||||
|
||||
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Get current user DID (empty string if not logged in)
|
||||
var currentUserDID string
|
||||
if user := middleware.GetUser(r); user != nil {
|
||||
currentUserDID = user.DID
|
||||
}
|
||||
|
||||
// Fetch featured repositories (top 6)
|
||||
featured, err := db.GetFeaturedRepositories(h.DB, 6)
|
||||
featured, err := db.GetFeaturedRepositories(h.DB, 6, currentUserDID)
|
||||
if err != nil {
|
||||
// Log error but continue - featured section will be empty
|
||||
featured = []db.FeaturedRepository{}
|
||||
@@ -39,6 +46,7 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
IconURL: repo.IconURL,
|
||||
StarCount: repo.StarCount,
|
||||
PullCount: repo.PullCount,
|
||||
IsStarred: repo.IsStarred,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,7 +85,13 @@ func (h *RecentPushesHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
userFilter = r.URL.Query().Get("q")
|
||||
}
|
||||
|
||||
pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter)
|
||||
// Get current user DID (empty string if not logged in)
|
||||
var currentUserDID string
|
||||
if user := middleware.GetUser(r); user != nil {
|
||||
currentUserDID = user.DID
|
||||
}
|
||||
|
||||
pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter, currentUserDID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
)
|
||||
|
||||
// SearchHandler handles the search page
|
||||
@@ -77,7 +78,13 @@ func (h *SearchResultsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
||||
offset, _ = strconv.Atoi(o)
|
||||
}
|
||||
|
||||
pushes, total, err := db.SearchPushes(h.DB, query, limit, offset)
|
||||
// Get current user DID (empty string if not logged in)
|
||||
var currentUserDID string
|
||||
if user := middleware.GetUser(r); user != nil {
|
||||
currentUserDID = user.DID
|
||||
}
|
||||
|
||||
pushes, total, err := db.SearchPushes(h.DB, query, limit, offset, currentUserDID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
||||
@@ -129,5 +129,5 @@ func (h *UpdateDefaultHoldHandler) ServeHTTP(w http.ResponseWriter, r *http.Requ
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<div class="success">✓ Default hold updated successfully!</div>`))
|
||||
w.Write([]byte(`<div class="success"><i data-lucide="check"></i> Default hold updated successfully!</div>`))
|
||||
}
|
||||
|
||||
@@ -24,9 +24,6 @@
|
||||
/* Button text color */
|
||||
--btn-text: #ffffff;
|
||||
|
||||
/* Theme toggle icon */
|
||||
--theme-icon: '🌙';
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
@@ -61,7 +58,7 @@
|
||||
--success: #34d399;
|
||||
--success-bg: #064e3b;
|
||||
--warning: #fbbf24;
|
||||
--warning-bg: #78350f;
|
||||
--warning-bg: #422006;
|
||||
--danger: #dc3545;
|
||||
--danger-bg: #7f1d1d;
|
||||
--bg: #2a2a2a;
|
||||
@@ -79,9 +76,6 @@
|
||||
/* Button text color */
|
||||
--btn-text: #ffffff;
|
||||
|
||||
/* Theme toggle icon */
|
||||
--theme-icon: '☀️';
|
||||
|
||||
/* Shadows - lighter for dark backgrounds */
|
||||
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.4);
|
||||
@@ -347,23 +341,55 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.theme-toggle-btn::before {
|
||||
content: var(--theme-icon);
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
.theme-toggle-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.theme-toggle-btn .theme-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: var(--danger);
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.delete-btn:hover .lucide {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: var(--button-primary);
|
||||
color: var(--btn-text);
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--secondary);
|
||||
border: none;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copy-btn:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.copy-btn:hover .lucide {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
/* Cards */
|
||||
@@ -414,6 +440,9 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
}
|
||||
|
||||
.push-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
color: var(--border-dark);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -441,6 +470,54 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Docker command component */
|
||||
.docker-command {
|
||||
display: inline-flex;
|
||||
position: relative;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
margin: 0.5rem 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.docker-command-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.docker-command-text {
|
||||
font-family: 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: var(--fg);
|
||||
flex: 0 1 auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.docker-command .copy-btn {
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: linear-gradient(to right, transparent, var(--code-bg) 30%);
|
||||
padding: 0.5rem;
|
||||
padding-left: 1.5rem;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
|
||||
.docker-command:hover .copy-btn {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* Digest tooltip on hover - using title attribute for native browser tooltip */
|
||||
.digest {
|
||||
cursor: default;
|
||||
@@ -449,23 +526,41 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
/* Digest copy button */
|
||||
.digest-copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border: none;
|
||||
color: var(--secondary);
|
||||
padding: 0.1rem 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.digest-copy-btn:hover {
|
||||
background: var(--hover-bg);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.digest-copy-btn:hover .lucide {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.digest-copy-btn .lucide {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.delete-btn .lucide {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-btn .lucide {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--border);
|
||||
}
|
||||
@@ -538,11 +633,22 @@ button:hover, .btn:hover, .btn-primary:hover, .btn-secondary:hover {
|
||||
.push-stat .star-icon {
|
||||
color: var(--star);
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: var(--star);
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.push-stat .star-icon.star-filled {
|
||||
fill: var(--star);
|
||||
}
|
||||
|
||||
.push-stat .pull-icon {
|
||||
color: var(--primary);
|
||||
font-size: 1rem;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.push-stat .stat-count {
|
||||
@@ -859,11 +965,36 @@ a.license-badge:hover {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.note a {
|
||||
color: var(--warning);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.note a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.note a:visited {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.success {
|
||||
background: var(--success-bg);
|
||||
border-left: 4px solid var(--success);
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.success .lucide {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: var(--success);
|
||||
stroke: var(--success);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -1075,11 +1206,30 @@ a.license-badge:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
/* Lucide icon base styles */
|
||||
.lucide {
|
||||
display: inline-block;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
vertical-align: middle;
|
||||
stroke-width: 2;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Star icon styles */
|
||||
.star-icon {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
transition: transform 0.2s ease;
|
||||
color:var(--star);
|
||||
color: var(--star);
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
stroke: var(--star);
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.star-icon.star-filled {
|
||||
fill: var(--star);
|
||||
}
|
||||
|
||||
.star-btn:hover:not(:disabled) .star-icon {
|
||||
@@ -1193,7 +1343,9 @@ a.license-badge:hover {
|
||||
|
||||
/* Offline manifest badge */
|
||||
.offline-badge {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--warning-bg);
|
||||
color: var(--warning);
|
||||
@@ -1204,9 +1356,16 @@ a.license-badge:hover {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.offline-badge .lucide {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Checking manifest badge (health check in progress) */
|
||||
.checking-badge {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
@@ -1217,6 +1376,11 @@ a.license-badge:hover {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.checking-badge .lucide {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
/* Hide offline manifests by default */
|
||||
.manifest-item[data-reachable="false"] {
|
||||
display: none;
|
||||
@@ -1295,6 +1459,11 @@ a.license-badge:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.manifest-type .lucide {
|
||||
width: 0.95rem;
|
||||
height: 0.95rem;
|
||||
}
|
||||
|
||||
.platform-count {
|
||||
color: var(--border-dark);
|
||||
font-size: 0.85rem;
|
||||
@@ -1431,11 +1600,22 @@ a.license-badge:hover {
|
||||
.featured-stat .star-icon {
|
||||
color: var(--star);
|
||||
font-size: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
stroke: var(--star);
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.featured-stat .star-icon.star-filled {
|
||||
fill: var(--star);
|
||||
}
|
||||
|
||||
.featured-stat .pull-icon {
|
||||
color: var(--primary);
|
||||
font-size: 1.1rem;
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.featured-stat .stat-count {
|
||||
@@ -1599,6 +1779,14 @@ a.license-badge:hover {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.benefit-icon .lucide {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
stroke-width: 1.5;
|
||||
color: var(--primary);
|
||||
stroke: var(--primary);
|
||||
}
|
||||
|
||||
.benefit-card h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -1634,6 +1822,20 @@ a.license-badge:hover {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.install-section a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.install-section a:hover {
|
||||
color: var(--primary-dark);
|
||||
}
|
||||
|
||||
.install-section a:visited {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -19,6 +19,19 @@ function updateThemeIcon() {
|
||||
if (!themeBtn) return;
|
||||
|
||||
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
const icon = themeBtn.querySelector('.theme-icon');
|
||||
|
||||
if (icon) {
|
||||
// In dark mode, show sun icon (to switch to light)
|
||||
// In light mode, show moon icon (to switch to dark)
|
||||
icon.setAttribute('data-lucide', currentTheme === 'dark' ? 'sun' : 'moon');
|
||||
|
||||
// Re-initialize Lucide icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
|
||||
}
|
||||
|
||||
@@ -26,11 +39,19 @@ function updateThemeIcon() {
|
||||
function copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
// Show success feedback
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = '✓ Copied!';
|
||||
const btn = event.target.closest('button');
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i data-lucide="check"></i> Copied!';
|
||||
// Re-initialize Lucide icons for the new icon
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
btn.innerHTML = originalHTML;
|
||||
// Re-initialize Lucide icons to restore original icon
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('Failed to copy:', err);
|
||||
@@ -75,7 +96,10 @@ function updateTimestamps() {
|
||||
}
|
||||
|
||||
// Initial timestamp update
|
||||
document.addEventListener('DOMContentLoaded', updateTimestamps);
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
updateTimestamps();
|
||||
updateThemeIcon();
|
||||
});
|
||||
|
||||
// Update timestamps after HTMX swaps
|
||||
document.addEventListener('htmx:afterSwap', updateTimestamps);
|
||||
@@ -90,10 +114,15 @@ function toggleRepo(name) {
|
||||
|
||||
if (details.style.display === 'none') {
|
||||
details.style.display = 'block';
|
||||
btn.textContent = '▲';
|
||||
btn.innerHTML = '<i data-lucide="chevron-up"></i>';
|
||||
} else {
|
||||
details.style.display = 'none';
|
||||
btn.textContent = '▼';
|
||||
btn.innerHTML = '<i data-lucide="chevron-down"></i>';
|
||||
}
|
||||
|
||||
// Re-initialize Lucide icons
|
||||
if (typeof lucide !== 'undefined') {
|
||||
lucide.createIcons();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +183,7 @@ async function toggleStar(handle, repository) {
|
||||
|
||||
try {
|
||||
// Check current state
|
||||
const isStarred = starIcon.textContent === '★';
|
||||
const isStarred = starIcon.classList.contains('star-filled');
|
||||
const method = isStarred ? 'DELETE' : 'POST';
|
||||
const url = `/api/stars/${handle}/${repository}`;
|
||||
|
||||
@@ -180,13 +209,13 @@ async function toggleStar(handle, repository) {
|
||||
|
||||
// Update UI optimistically
|
||||
if (data.starred) {
|
||||
starIcon.textContent = '★';
|
||||
starIcon.classList.add('star-filled');
|
||||
starBtn.classList.add('starred');
|
||||
// Optimistically increment count
|
||||
const currentCount = parseInt(starCountEl.textContent) || 0;
|
||||
starCountEl.textContent = currentCount + 1;
|
||||
} else {
|
||||
starIcon.textContent = '☆';
|
||||
starIcon.classList.remove('star-filled');
|
||||
starBtn.classList.remove('starred');
|
||||
// Optimistically decrement count
|
||||
const currentCount = parseInt(starCountEl.textContent) || 0;
|
||||
@@ -229,7 +258,7 @@ async function loadStarStatus() {
|
||||
const starData = await starResponse.json();
|
||||
console.log('Star status data:', starData);
|
||||
if (starData.starred) {
|
||||
starIcon.textContent = '★';
|
||||
starIcon.classList.add('star-filled');
|
||||
starBtn.classList.add('starred');
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{{ define "docker-command" }}
|
||||
{{/*
|
||||
Docker command component - displays a docker command with icon and copy button
|
||||
|
||||
Expects: string - the docker command to display
|
||||
Usage: {{ template "docker-command" "docker pull atcr.io/alice/myapp:latest" }}
|
||||
*/}}
|
||||
<div class="docker-command">
|
||||
<i data-lucide="terminal" class="docker-command-icon"></i>
|
||||
<code class="docker-command-text">{{ . }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard(this.getAttribute('data-cmd'))" data-cmd="{{ . }}">
|
||||
<i data-lucide="copy"></i>
|
||||
</button>
|
||||
</div>
|
||||
{{ end }}
|
||||
@@ -15,6 +15,20 @@
|
||||
<!-- HTMX -->
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script>
|
||||
|
||||
<!-- Lucide Icons -->
|
||||
<script src="https://unpkg.com/lucide@latest"></script>
|
||||
|
||||
<!-- App Scripts -->
|
||||
<script src="/js/app.js"></script>
|
||||
<script>
|
||||
// Initialize Lucide icons after DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
lucide.createIcons();
|
||||
|
||||
// Re-initialize icons after HTMX swaps content
|
||||
document.body.addEventListener('htmx:afterSwap', () => {
|
||||
lucide.createIcons();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
{{ define "nav-theme-toggle" }}
|
||||
<button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme"></button>
|
||||
<button id="theme-toggle" onclick="toggleTheme()" class="btn-link theme-toggle-btn" aria-label="Toggle theme">
|
||||
<i data-lucide="moon" class="theme-icon"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
|
||||
@@ -31,11 +31,11 @@
|
||||
</div>
|
||||
<div class="featured-stats">
|
||||
<span class="featured-stat">
|
||||
<span class="star-icon">★</span>
|
||||
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
|
||||
<span class="stat-count">{{ .StarCount }}</span>
|
||||
</span>
|
||||
<span class="featured-stat">
|
||||
<span class="pull-icon">↓</span>
|
||||
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
|
||||
<span class="stat-count">{{ .PullCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -39,17 +39,17 @@
|
||||
<!-- Benefit Cards -->
|
||||
<div class="hero-benefits">
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">🐳</div>
|
||||
<div class="benefit-icon"><i data-lucide="ship"></i></div>
|
||||
<h3>Works with Docker</h3>
|
||||
<p>Use docker push & pull. No new tools to learn.</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">⚓</div>
|
||||
<div class="benefit-icon"><i data-lucide="anchor"></i></div>
|
||||
<h3>Your Data</h3>
|
||||
<p>Join shared holds or captain your own storage.</p>
|
||||
</div>
|
||||
<div class="benefit-card">
|
||||
<div class="benefit-icon">🧭</div>
|
||||
<div class="benefit-icon"><i data-lucide="compass"></i></div>
|
||||
<h3>Discover Images</h3>
|
||||
<p>Browse and star public container registries.</p>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<div class="repo-info-row">
|
||||
<div class="repo-actions">
|
||||
<button class="star-btn{{ if .IsStarred }} starred{{ end }}" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')">
|
||||
<span class="star-icon" id="star-icon">{{ if .IsStarred }}★{{ else }}☆{{ end }}</span>
|
||||
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}" id="star-icon"></i>
|
||||
<span class="star-count" id="star-count">{{ .StarCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -81,19 +81,9 @@
|
||||
<h3>Pull this image</h3>
|
||||
{{ if .Tags }}
|
||||
{{ $firstTag := index .Tags 0 }}
|
||||
<div class="push-command">
|
||||
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}')">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
|
||||
{{ else }}
|
||||
<div class="push-command">
|
||||
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest')">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,7 +127,7 @@
|
||||
hx-confirm="Delete tag {{ .Tag.Tag }}?"
|
||||
hx-target="#tag-{{ .Tag.Tag }}"
|
||||
hx-swap="outerHTML">
|
||||
🗑️
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -146,7 +136,7 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="digest-container">
|
||||
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
{{ if .Platforms }}
|
||||
<div class="platforms-inline">
|
||||
@@ -157,12 +147,7 @@
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="push-command">
|
||||
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}</code>
|
||||
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}')">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
@@ -187,23 +172,23 @@
|
||||
<div class="manifest-item-header">
|
||||
<div>
|
||||
{{ if .IsManifestList }}
|
||||
<span class="manifest-type">📦 Multi-arch</span>
|
||||
<span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span>
|
||||
{{ else }}
|
||||
<span class="manifest-type">📄 Image</span>
|
||||
<span class="manifest-type"><i data-lucide="file-text"></i> Image</span>
|
||||
{{ end }}
|
||||
{{ if .Pending }}
|
||||
<span class="checking-badge"
|
||||
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
|
||||
hx-trigger="load delay:2s"
|
||||
hx-swap="outerHTML">
|
||||
🔄 Checking...
|
||||
<i data-lucide="rotate-cw"></i> Checking...
|
||||
</span>
|
||||
{{ else if not .Reachable }}
|
||||
<span class="offline-badge">⚠️ Offline</span>
|
||||
<span class="offline-badge"><i data-lucide="alert-triangle"></i> Offline</span>
|
||||
{{ end }}
|
||||
<div class="digest-container">
|
||||
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; align-items: center;">
|
||||
@@ -213,7 +198,7 @@
|
||||
{{ if $.IsOwner }}
|
||||
<button class="delete-btn"
|
||||
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')">
|
||||
🗑️
|
||||
<i data-lucide="trash-2"></i>
|
||||
</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
@@ -65,8 +65,7 @@
|
||||
<h3>First Time Setup</h3>
|
||||
<ol>
|
||||
<li>Install credential helper:
|
||||
<pre><code>brew install atcr-credential-helper</code></pre>
|
||||
(or download from releases)
|
||||
<pre><code>curl -fsSL atcr.io/static/install.sh | bash</code></pre>
|
||||
</li>
|
||||
<li>Configure Docker to use the helper. Add to <code>~/.docker/config.json</code>:
|
||||
<pre><code>{
|
||||
@@ -76,7 +75,7 @@
|
||||
}</code></pre>
|
||||
</li>
|
||||
<li>Run any Docker command:
|
||||
<pre><code>docker pull {{ .RegistryURL }}/{{ .Profile.Handle }}/myimage</code></pre>
|
||||
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}
|
||||
</li>
|
||||
<li>Browser will open for authorization - click Approve</li>
|
||||
<li>Done! Device is automatically authorized</li>
|
||||
@@ -205,7 +204,7 @@
|
||||
.devices-section .setup-instructions {
|
||||
margin: 1rem 0;
|
||||
padding: 1.5rem;
|
||||
background: #e3f2fd;
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.devices-section .setup-instructions h3 {
|
||||
@@ -218,8 +217,9 @@
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.devices-section .setup-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
@@ -231,10 +231,21 @@
|
||||
.devices-section .fallback-note {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
background: var(--warning-bg);
|
||||
border: 1px solid var(--warning);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.devices-section .fallback-note a {
|
||||
color: var(--warning);
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.devices-section .fallback-note a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
.devices-section .fallback-note a:visited {
|
||||
color: var(--warning);
|
||||
}
|
||||
.devices-section table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -244,10 +255,10 @@
|
||||
.devices-section td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.devices-section th {
|
||||
background: #f5f5f5;
|
||||
background: var(--code-bg);
|
||||
font-weight: bold;
|
||||
}
|
||||
.devices-section .btn-danger {
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
</div>
|
||||
<div class="push-stats">
|
||||
<span class="push-stat">
|
||||
<span class="star-icon">★</span>
|
||||
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
|
||||
<span class="stat-count">{{ .StarCount }}</span>
|
||||
</span>
|
||||
<span class="push-stat">
|
||||
<span class="pull-icon">↓</span>
|
||||
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
|
||||
<span class="stat-count">{{ .PullCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -35,9 +35,8 @@
|
||||
<div class="push-details">
|
||||
<div class="digest-container">
|
||||
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button>
|
||||
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy"></i></button>
|
||||
</div>
|
||||
<span class="separator">•</span>
|
||||
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
||||
{{ timeAgo .CreatedAt }}
|
||||
</time>
|
||||
|
||||
Reference in New Issue
Block a user