+3
pkg/appview/db/models.go
+3
pkg/appview/db/models.go
···
70
70
IconURL string
71
71
StarCount int
72
72
PullCount int
73
+
IsStarred bool // Whether the current user has starred this repository
73
74
CreatedAt time.Time
74
75
HoldEndpoint string // Hold endpoint for health checking
75
76
Reachable bool // Whether the hold endpoint is reachable
···
114
115
IconURL string
115
116
StarCount int
116
117
PullCount int
118
+
IsStarred bool // Whether the current user has starred this repository
117
119
}
118
120
119
121
// RepositoryWithStats combines repository data with statistics
···
131
133
IconURL string
132
134
StarCount int
133
135
PullCount int
136
+
IsStarred bool // Whether the current user has starred this repository
134
137
}
135
138
136
139
// PlatformInfo represents platform information (OS/Architecture)
+19
-10
pkg/appview/db/queries.go
+19
-10
pkg/appview/db/queries.go
···
31
31
}
32
32
33
33
// GetRecentPushes fetches recent pushes with pagination
34
-
func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string) ([]Push, int, error) {
34
+
func GetRecentPushes(db *sql.DB, limit, offset int, userFilter string, currentUserDID string) ([]Push, int, error) {
35
35
query := `
36
36
SELECT
37
37
u.did,
···
44
44
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
45
45
COALESCE(rs.pull_count, 0),
46
46
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
47
+
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
47
48
t.created_at,
48
49
m.hold_endpoint
49
50
FROM tags t
···
52
53
LEFT JOIN repository_stats rs ON t.did = rs.did AND t.repository = rs.repository
53
54
`
54
55
55
-
args := []any{}
56
+
args := []any{currentUserDID}
56
57
57
58
if userFilter != "" {
58
59
query += " WHERE u.handle = ? OR u.did = ?"
···
71
72
var pushes []Push
72
73
for rows.Next() {
73
74
var p Push
74
-
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 {
75
+
var isStarredInt int
76
+
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 {
75
77
return nil, 0, err
76
78
}
79
+
p.IsStarred = isStarredInt > 0
77
80
pushes = append(pushes, p)
78
81
}
79
82
···
95
98
}
96
99
97
100
// SearchPushes searches for pushes matching the query across handles, DIDs, repositories, and annotations
98
-
func SearchPushes(db *sql.DB, query string, limit, offset int) ([]Push, int, error) {
101
+
func SearchPushes(db *sql.DB, query string, limit, offset int, currentUserDID string) ([]Push, int, error) {
99
102
// Escape LIKE wildcards so they're treated literally
100
103
query = escapeLikePattern(query)
101
104
···
114
117
COALESCE((SELECT value FROM repository_annotations WHERE did = u.did AND repository = t.repository AND key = 'io.atcr.icon'), ''),
115
118
COALESCE(rs.pull_count, 0),
116
119
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = u.did AND repository = t.repository), 0),
120
+
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = u.did AND repository = t.repository), 0),
117
121
t.created_at,
118
122
m.hold_endpoint
119
123
FROM tags t
···
132
136
LIMIT ? OFFSET ?
133
137
`
134
138
135
-
rows, err := db.Query(sqlQuery, searchPattern, query, searchPattern, searchPattern, limit, offset)
139
+
rows, err := db.Query(sqlQuery, currentUserDID, searchPattern, query, searchPattern, searchPattern, limit, offset)
136
140
if err != nil {
137
141
return nil, 0, err
138
142
}
···
141
145
var pushes []Push
142
146
for rows.Next() {
143
147
var p Push
144
-
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 {
148
+
var isStarredInt int
149
+
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 {
145
150
return nil, 0, err
146
151
}
152
+
p.IsStarred = isStarredInt > 0
147
153
pushes = append(pushes, p)
148
154
}
149
155
···
1571
1577
}
1572
1578
1573
1579
// GetFeaturedRepositories fetches top repositories sorted by stars and pulls
1574
-
func GetFeaturedRepositories(db *sql.DB, limit int) ([]FeaturedRepository, error) {
1580
+
func GetFeaturedRepositories(db *sql.DB, limit int, currentUserDID string) ([]FeaturedRepository, error) {
1575
1581
query := `
1576
1582
WITH latest_manifests AS (
1577
1583
SELECT did, repository, MAX(id) as latest_id
···
1596
1602
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
1597
1603
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
1598
1604
rs.pull_count,
1599
-
rs.star_count
1605
+
rs.star_count,
1606
+
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0)
1600
1607
FROM latest_manifests lm
1601
1608
JOIN manifests m ON lm.latest_id = m.id
1602
1609
JOIN users u ON m.did = u.did
···
1605
1612
LIMIT ?
1606
1613
`
1607
1614
1608
-
rows, err := db.Query(query, limit)
1615
+
rows, err := db.Query(query, currentUserDID, limit)
1609
1616
if err != nil {
1610
1617
return nil, err
1611
1618
}
···
1614
1621
var featured []FeaturedRepository
1615
1622
for rows.Next() {
1616
1623
var f FeaturedRepository
1624
+
var isStarredInt int
1617
1625
1618
1626
if err := rows.Scan(&f.OwnerDID, &f.OwnerHandle, &f.Repository,
1619
-
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount); err != nil {
1627
+
&f.Title, &f.Description, &f.IconURL, &f.PullCount, &f.StarCount, &isStarredInt); err != nil {
1620
1628
return nil, err
1621
1629
}
1630
+
f.IsStarred = isStarredInt > 0
1622
1631
1623
1632
featured = append(featured, f)
1624
1633
}
+16
-2
pkg/appview/handlers/home.go
+16
-2
pkg/appview/handlers/home.go
···
11
11
12
12
"atcr.io/pkg/appview/db"
13
13
"atcr.io/pkg/appview/holdhealth"
14
+
"atcr.io/pkg/appview/middleware"
14
15
)
15
16
16
17
// HomeHandler handles the home page
···
21
22
}
22
23
23
24
func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
25
+
// Get current user DID (empty string if not logged in)
26
+
var currentUserDID string
27
+
if user := middleware.GetUser(r); user != nil {
28
+
currentUserDID = user.DID
29
+
}
30
+
24
31
// Fetch featured repositories (top 6)
25
-
featured, err := db.GetFeaturedRepositories(h.DB, 6)
32
+
featured, err := db.GetFeaturedRepositories(h.DB, 6, currentUserDID)
26
33
if err != nil {
27
34
// Log error but continue - featured section will be empty
28
35
featured = []db.FeaturedRepository{}
···
39
46
IconURL: repo.IconURL,
40
47
StarCount: repo.StarCount,
41
48
PullCount: repo.PullCount,
49
+
IsStarred: repo.IsStarred,
42
50
}
43
51
}
44
52
···
77
85
userFilter = r.URL.Query().Get("q")
78
86
}
79
87
80
-
pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter)
88
+
// Get current user DID (empty string if not logged in)
89
+
var currentUserDID string
90
+
if user := middleware.GetUser(r); user != nil {
91
+
currentUserDID = user.DID
92
+
}
93
+
94
+
pushes, total, err := db.GetRecentPushes(h.DB, limit, offset, userFilter, currentUserDID)
81
95
if err != nil {
82
96
http.Error(w, err.Error(), http.StatusInternalServerError)
83
97
return
+8
-1
pkg/appview/handlers/search.go
+8
-1
pkg/appview/handlers/search.go
···
8
8
"strings"
9
9
10
10
"atcr.io/pkg/appview/db"
11
+
"atcr.io/pkg/appview/middleware"
11
12
)
12
13
13
14
// SearchHandler handles the search page
···
77
78
offset, _ = strconv.Atoi(o)
78
79
}
79
80
80
-
pushes, total, err := db.SearchPushes(h.DB, query, limit, offset)
81
+
// Get current user DID (empty string if not logged in)
82
+
var currentUserDID string
83
+
if user := middleware.GetUser(r); user != nil {
84
+
currentUserDID = user.DID
85
+
}
86
+
87
+
pushes, total, err := db.SearchPushes(h.DB, query, limit, offset, currentUserDID)
81
88
if err != nil {
82
89
http.Error(w, err.Error(), http.StatusInternalServerError)
83
90
return
+1
-1
pkg/appview/handlers/settings.go
+1
-1
pkg/appview/handlers/settings.go
+226
-24
pkg/appview/static/css/style.css
+226
-24
pkg/appview/static/css/style.css
···
24
24
/* Button text color */
25
25
--btn-text: #ffffff;
26
26
27
-
/* Theme toggle icon */
28
-
--theme-icon: '🌙';
29
-
30
27
/* Shadows */
31
28
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.05);
32
29
--shadow-md: 0 2px 4px rgba(0, 0, 0, 0.1);
···
61
58
--success: #34d399;
62
59
--success-bg: #064e3b;
63
60
--warning: #fbbf24;
64
-
--warning-bg: #78350f;
61
+
--warning-bg: #422006;
65
62
--danger: #dc3545;
66
63
--danger-bg: #7f1d1d;
67
64
--bg: #2a2a2a;
···
78
75
79
76
/* Button text color */
80
77
--btn-text: #ffffff;
81
-
82
-
/* Theme toggle icon */
83
-
--theme-icon: '☀️';
84
78
85
79
/* Shadows - lighter for dark backgrounds */
86
80
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
···
347
341
text-decoration: none;
348
342
}
349
343
350
-
.theme-toggle-btn::before {
351
-
content: var(--theme-icon);
352
-
font-size: 1.2rem;
353
-
cursor: pointer;
344
+
.theme-toggle-btn {
345
+
display: inline-flex;
346
+
align-items: center;
347
+
justify-content: center;
348
+
}
349
+
350
+
.theme-toggle-btn .theme-icon {
351
+
width: 1.25rem;
352
+
height: 1.25rem;
354
353
}
355
354
356
355
.delete-btn {
357
-
background: var(--danger);
356
+
background: transparent;
357
+
border: none;
358
+
color: var(--danger);
358
359
padding: 0.25rem 0.5rem;
359
360
font-size: 0.85rem;
361
+
cursor: pointer;
362
+
transition: all 0.2s ease;
363
+
display: inline-flex;
364
+
align-items: center;
365
+
}
366
+
367
+
.delete-btn:hover {
368
+
color: var(--danger);
369
+
}
370
+
371
+
.delete-btn:hover .lucide {
372
+
transform: scale(1.2);
360
373
}
361
374
362
375
.copy-btn {
363
-
padding: 0.25rem 0.75rem;
364
-
background: var(--button-primary);
365
-
color: var(--btn-text);
376
+
padding: 0.25rem 0.5rem;
377
+
background: transparent;
378
+
color: var(--secondary);
379
+
border: none;
366
380
font-size: 0.85rem;
381
+
cursor: pointer;
382
+
transition: all 0.2s ease;
383
+
display: inline-flex;
384
+
align-items: center;
385
+
}
386
+
387
+
.copy-btn:hover {
388
+
color: var(--primary);
389
+
}
390
+
391
+
.copy-btn:hover .lucide {
392
+
transform: scale(1.2);
367
393
}
368
394
369
395
/* Cards */
···
414
440
}
415
441
416
442
.push-details {
443
+
display: flex;
444
+
align-items: center;
445
+
gap: 1rem;
417
446
color: var(--border-dark);
418
447
font-size: 0.9rem;
419
448
margin-bottom: 0.75rem;
···
441
470
gap: 0.5rem;
442
471
}
443
472
473
+
/* Docker command component */
474
+
.docker-command {
475
+
display: inline-flex;
476
+
position: relative;
477
+
align-items: center;
478
+
gap: 0.5rem;
479
+
background: var(--code-bg);
480
+
border: 1px solid var(--border);
481
+
border-radius: 6px;
482
+
padding: 0.75rem;
483
+
margin: 0.5rem 0;
484
+
max-width: 100%;
485
+
}
486
+
487
+
.docker-command-icon {
488
+
width: 1.25rem;
489
+
height: 1.25rem;
490
+
color: var(--secondary);
491
+
flex-shrink: 0;
492
+
}
493
+
494
+
.docker-command-text {
495
+
font-family: 'Monaco', 'Courier New', monospace;
496
+
font-size: 0.85rem;
497
+
color: var(--fg);
498
+
flex: 0 1 auto;
499
+
word-break: break-all;
500
+
}
501
+
502
+
.docker-command .copy-btn {
503
+
position: absolute;
504
+
right: 0.5rem;
505
+
top: 50%;
506
+
transform: translateY(-50%);
507
+
background: linear-gradient(to right, transparent, var(--code-bg) 30%);
508
+
padding: 0.5rem;
509
+
padding-left: 1.5rem;
510
+
border-radius: 4px;
511
+
opacity: 0;
512
+
visibility: hidden;
513
+
transition: opacity 0.2s, visibility 0.2s;
514
+
}
515
+
516
+
.docker-command:hover .copy-btn {
517
+
opacity: 1;
518
+
visibility: visible;
519
+
}
520
+
444
521
/* Digest tooltip on hover - using title attribute for native browser tooltip */
445
522
.digest {
446
523
cursor: default;
···
449
526
/* Digest copy button */
450
527
.digest-copy-btn {
451
528
background: transparent;
452
-
border: 1px solid var(--border);
529
+
border: none;
453
530
color: var(--secondary);
454
531
padding: 0.1rem 0.4rem;
455
-
font-size: 0.75rem;
456
-
border-radius: 3px;
457
532
cursor: pointer;
458
-
transition: all 0.2s;
533
+
transition: all 0.2s ease;
459
534
display: inline-flex;
460
535
align-items: center;
461
536
}
462
537
463
538
.digest-copy-btn:hover {
464
-
background: var(--hover-bg);
465
-
border-color: var(--primary);
466
539
color: var(--primary);
467
540
}
468
541
542
+
.digest-copy-btn:hover .lucide {
543
+
transform: scale(1.2);
544
+
}
545
+
546
+
.digest-copy-btn .lucide {
547
+
width: 0.875rem;
548
+
height: 0.875rem;
549
+
transition: transform 0.2s ease;
550
+
}
551
+
552
+
.delete-btn .lucide {
553
+
width: 1rem;
554
+
height: 1rem;
555
+
transition: transform 0.2s ease;
556
+
}
557
+
558
+
.copy-btn .lucide {
559
+
width: 1rem;
560
+
height: 1rem;
561
+
transition: transform 0.2s ease;
562
+
}
563
+
469
564
.separator {
470
565
color: var(--border);
471
566
}
···
538
633
.push-stat .star-icon {
539
634
color: var(--star);
540
635
font-size: 1rem;
636
+
width: 1rem;
637
+
height: 1rem;
638
+
stroke: var(--star);
639
+
fill: none;
640
+
}
641
+
642
+
.push-stat .star-icon.star-filled {
643
+
fill: var(--star);
541
644
}
542
645
543
646
.push-stat .pull-icon {
544
647
color: var(--primary);
545
648
font-size: 1rem;
649
+
width: 1rem;
650
+
height: 1rem;
651
+
stroke: var(--primary);
546
652
}
547
653
548
654
.push-stat .stat-count {
···
859
965
margin: 1rem 0;
860
966
}
861
967
968
+
.note a {
969
+
color: var(--warning);
970
+
text-decoration: underline;
971
+
font-weight: 500;
972
+
}
973
+
974
+
.note a:hover {
975
+
color: var(--primary);
976
+
}
977
+
978
+
.note a:visited {
979
+
color: var(--warning);
980
+
}
981
+
862
982
.success {
863
983
background: var(--success-bg);
864
984
border-left: 4px solid var(--success);
865
985
padding: 1rem;
866
986
margin: 1rem 0;
987
+
display: flex;
988
+
align-items: center;
989
+
gap: 0.5rem;
990
+
}
991
+
992
+
.success .lucide {
993
+
width: 1.25rem;
994
+
height: 1.25rem;
995
+
color: var(--success);
996
+
stroke: var(--success);
997
+
flex-shrink: 0;
867
998
}
868
999
869
1000
.error {
···
1075
1206
background: var(--hover-bg);
1076
1207
}
1077
1208
1209
+
/* Lucide icon base styles */
1210
+
.lucide {
1211
+
display: inline-block;
1212
+
width: 1em;
1213
+
height: 1em;
1214
+
vertical-align: middle;
1215
+
stroke-width: 2;
1216
+
transition: transform 0.2s ease;
1217
+
}
1218
+
1219
+
/* Star icon styles */
1078
1220
.star-icon {
1079
1221
font-size: 1.25rem;
1080
1222
line-height: 1;
1081
1223
transition: transform 0.2s ease;
1082
-
color:var(--star);
1224
+
color: var(--star);
1225
+
width: 1.25rem;
1226
+
height: 1.25rem;
1227
+
stroke: var(--star);
1228
+
fill: none;
1229
+
}
1230
+
1231
+
.star-icon.star-filled {
1232
+
fill: var(--star);
1083
1233
}
1084
1234
1085
1235
.star-btn:hover:not(:disabled) .star-icon {
···
1193
1343
1194
1344
/* Offline manifest badge */
1195
1345
.offline-badge {
1196
-
display: inline-block;
1346
+
display: inline-flex;
1347
+
align-items: center;
1348
+
gap: 0.35rem;
1197
1349
padding: 0.25rem 0.5rem;
1198
1350
background: var(--warning-bg);
1199
1351
color: var(--warning);
···
1204
1356
margin-left: 0.5rem;
1205
1357
}
1206
1358
1359
+
.offline-badge .lucide {
1360
+
width: 0.875rem;
1361
+
height: 0.875rem;
1362
+
}
1363
+
1207
1364
/* Checking manifest badge (health check in progress) */
1208
1365
.checking-badge {
1209
-
display: inline-block;
1366
+
display: inline-flex;
1367
+
align-items: center;
1368
+
gap: 0.35rem;
1210
1369
padding: 0.25rem 0.5rem;
1211
1370
background: #e3f2fd;
1212
1371
color: #1976d2;
···
1215
1374
font-size: 0.85rem;
1216
1375
font-weight: 600;
1217
1376
margin-left: 0.5rem;
1377
+
}
1378
+
1379
+
.checking-badge .lucide {
1380
+
width: 0.875rem;
1381
+
height: 0.875rem;
1218
1382
}
1219
1383
1220
1384
/* Hide offline manifests by default */
···
1293
1457
font-size: 0.9rem;
1294
1458
font-weight: 500;
1295
1459
color: var(--secondary);
1460
+
}
1461
+
1462
+
.manifest-type .lucide {
1463
+
width: 0.95rem;
1464
+
height: 0.95rem;
1296
1465
}
1297
1466
1298
1467
.platform-count {
···
1431
1600
.featured-stat .star-icon {
1432
1601
color: var(--star);
1433
1602
font-size: 1.1rem;
1603
+
width: 1.1rem;
1604
+
height: 1.1rem;
1605
+
stroke: var(--star);
1606
+
fill: none;
1607
+
}
1608
+
1609
+
.featured-stat .star-icon.star-filled {
1610
+
fill: var(--star);
1434
1611
}
1435
1612
1436
1613
.featured-stat .pull-icon {
1437
1614
color: var(--primary);
1438
1615
font-size: 1.1rem;
1616
+
width: 1.1rem;
1617
+
height: 1.1rem;
1618
+
stroke: var(--primary);
1439
1619
}
1440
1620
1441
1621
.featured-stat .stat-count {
···
1599
1779
line-height: 1;
1600
1780
}
1601
1781
1782
+
.benefit-icon .lucide {
1783
+
width: 3rem;
1784
+
height: 3rem;
1785
+
stroke-width: 1.5;
1786
+
color: var(--primary);
1787
+
stroke: var(--primary);
1788
+
}
1789
+
1602
1790
.benefit-card h3 {
1603
1791
font-size: 1.2rem;
1604
1792
margin-bottom: 0.75rem;
···
1632
1820
margin: 1.5rem 0 0.5rem;
1633
1821
color: var(--border-dark);
1634
1822
font-size: 1.1rem;
1823
+
}
1824
+
1825
+
.install-section a {
1826
+
color: var(--primary);
1827
+
text-decoration: underline;
1828
+
font-weight: 500;
1829
+
}
1830
+
1831
+
.install-section a:hover {
1832
+
color: var(--primary-dark);
1833
+
}
1834
+
1835
+
.install-section a:visited {
1836
+
color: var(--primary);
1635
1837
}
1636
1838
1637
1839
.code-block {
+40
-11
pkg/appview/static/js/app.js
+40
-11
pkg/appview/static/js/app.js
···
19
19
if (!themeBtn) return;
20
20
21
21
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
22
+
const icon = themeBtn.querySelector('.theme-icon');
23
+
24
+
if (icon) {
25
+
// In dark mode, show sun icon (to switch to light)
26
+
// In light mode, show moon icon (to switch to dark)
27
+
icon.setAttribute('data-lucide', currentTheme === 'dark' ? 'sun' : 'moon');
28
+
29
+
// Re-initialize Lucide icons
30
+
if (typeof lucide !== 'undefined') {
31
+
lucide.createIcons();
32
+
}
33
+
}
34
+
22
35
themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
23
36
}
24
37
···
26
39
function copyToClipboard(text) {
27
40
navigator.clipboard.writeText(text).then(() => {
28
41
// Show success feedback
29
-
const btn = event.target;
30
-
const originalText = btn.textContent;
31
-
btn.textContent = '✓ Copied!';
42
+
const btn = event.target.closest('button');
43
+
const originalHTML = btn.innerHTML;
44
+
btn.innerHTML = '<i data-lucide="check"></i> Copied!';
45
+
// Re-initialize Lucide icons for the new icon
46
+
if (typeof lucide !== 'undefined') {
47
+
lucide.createIcons();
48
+
}
32
49
setTimeout(() => {
33
-
btn.textContent = originalText;
50
+
btn.innerHTML = originalHTML;
51
+
// Re-initialize Lucide icons to restore original icon
52
+
if (typeof lucide !== 'undefined') {
53
+
lucide.createIcons();
54
+
}
34
55
}, 2000);
35
56
}).catch(err => {
36
57
console.error('Failed to copy:', err);
···
75
96
}
76
97
77
98
// Initial timestamp update
78
-
document.addEventListener('DOMContentLoaded', updateTimestamps);
99
+
document.addEventListener('DOMContentLoaded', () => {
100
+
updateTimestamps();
101
+
updateThemeIcon();
102
+
});
79
103
80
104
// Update timestamps after HTMX swaps
81
105
document.addEventListener('htmx:afterSwap', updateTimestamps);
···
90
114
91
115
if (details.style.display === 'none') {
92
116
details.style.display = 'block';
93
-
btn.textContent = '▲';
117
+
btn.innerHTML = '<i data-lucide="chevron-up"></i>';
94
118
} else {
95
119
details.style.display = 'none';
96
-
btn.textContent = '▼';
120
+
btn.innerHTML = '<i data-lucide="chevron-down"></i>';
121
+
}
122
+
123
+
// Re-initialize Lucide icons
124
+
if (typeof lucide !== 'undefined') {
125
+
lucide.createIcons();
97
126
}
98
127
}
99
128
···
154
183
155
184
try {
156
185
// Check current state
157
-
const isStarred = starIcon.textContent === '★';
186
+
const isStarred = starIcon.classList.contains('star-filled');
158
187
const method = isStarred ? 'DELETE' : 'POST';
159
188
const url = `/api/stars/${handle}/${repository}`;
160
189
···
180
209
181
210
// Update UI optimistically
182
211
if (data.starred) {
183
-
starIcon.textContent = '★';
212
+
starIcon.classList.add('star-filled');
184
213
starBtn.classList.add('starred');
185
214
// Optimistically increment count
186
215
const currentCount = parseInt(starCountEl.textContent) || 0;
187
216
starCountEl.textContent = currentCount + 1;
188
217
} else {
189
-
starIcon.textContent = '☆';
218
+
starIcon.classList.remove('star-filled');
190
219
starBtn.classList.remove('starred');
191
220
// Optimistically decrement count
192
221
const currentCount = parseInt(starCountEl.textContent) || 0;
···
229
258
const starData = await starResponse.json();
230
259
console.log('Star status data:', starData);
231
260
if (starData.starred) {
232
-
starIcon.textContent = '★';
261
+
starIcon.classList.add('star-filled');
233
262
starBtn.classList.add('starred');
234
263
}
235
264
} else {
+15
pkg/appview/templates/components/docker-command.html
+15
pkg/appview/templates/components/docker-command.html
···
1
+
{{ define "docker-command" }}
2
+
{{/*
3
+
Docker command component - displays a docker command with icon and copy button
4
+
5
+
Expects: string - the docker command to display
6
+
Usage: {{ template "docker-command" "docker pull atcr.io/alice/myapp:latest" }}
7
+
*/}}
8
+
<div class="docker-command">
9
+
<i data-lucide="terminal" class="docker-command-icon"></i>
10
+
<code class="docker-command-text">{{ . }}</code>
11
+
<button class="copy-btn" onclick="copyToClipboard(this.getAttribute('data-cmd'))" data-cmd="{{ . }}">
12
+
<i data-lucide="copy"></i>
13
+
</button>
14
+
</div>
15
+
{{ end }}
+14
pkg/appview/templates/components/head.html
+14
pkg/appview/templates/components/head.html
···
15
15
<!-- HTMX -->
16
16
<script src="https://unpkg.com/htmx.org@2.0.8/dist/htmx.min.js"></script>
17
17
18
+
<!-- Lucide Icons -->
19
+
<script src="https://unpkg.com/lucide@latest"></script>
20
+
18
21
<!-- App Scripts -->
19
22
<script src="/js/app.js"></script>
23
+
<script>
24
+
// Initialize Lucide icons after DOM is loaded
25
+
document.addEventListener('DOMContentLoaded', () => {
26
+
lucide.createIcons();
27
+
28
+
// Re-initialize icons after HTMX swaps content
29
+
document.body.addEventListener('htmx:afterSwap', () => {
30
+
lucide.createIcons();
31
+
});
32
+
});
33
+
</script>
20
34
{{ end }}
+2
-2
pkg/appview/templates/components/repo-card.html
+2
-2
pkg/appview/templates/components/repo-card.html
···
31
31
</div>
32
32
<div class="featured-stats">
33
33
<span class="featured-stat">
34
-
<span class="star-icon">★</span>
34
+
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
35
35
<span class="stat-count">{{ .StarCount }}</span>
36
36
</span>
37
37
<span class="featured-stat">
38
-
<span class="pull-icon">↓</span>
38
+
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
39
39
<span class="stat-count">{{ .PullCount }}</span>
40
40
</span>
41
41
</div>
+3
-3
pkg/appview/templates/pages/home.html
+3
-3
pkg/appview/templates/pages/home.html
···
39
39
<!-- Benefit Cards -->
40
40
<div class="hero-benefits">
41
41
<div class="benefit-card">
42
-
<div class="benefit-icon">🐳</div>
42
+
<div class="benefit-icon"><i data-lucide="ship"></i></div>
43
43
<h3>Works with Docker</h3>
44
44
<p>Use docker push & pull. No new tools to learn.</p>
45
45
</div>
46
46
<div class="benefit-card">
47
-
<div class="benefit-icon">⚓</div>
47
+
<div class="benefit-icon"><i data-lucide="anchor"></i></div>
48
48
<h3>Your Data</h3>
49
49
<p>Join shared holds or captain your own storage.</p>
50
50
</div>
51
51
<div class="benefit-card">
52
-
<div class="benefit-icon">🧭</div>
52
+
<div class="benefit-icon"><i data-lucide="compass"></i></div>
53
53
<h3>Discover Images</h3>
54
54
<p>Browse and star public container registries.</p>
55
55
</div>
+12
-27
pkg/appview/templates/pages/repository.html
+12
-27
pkg/appview/templates/pages/repository.html
···
34
34
<div class="repo-info-row">
35
35
<div class="repo-actions">
36
36
<button class="star-btn{{ if .IsStarred }} starred{{ end }}" id="star-btn" onclick="toggleStar('{{ .Owner.Handle }}', '{{ .Repository.Name }}')">
37
-
<span class="star-icon" id="star-icon">{{ if .IsStarred }}★{{ else }}☆{{ end }}</span>
37
+
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}" id="star-icon"></i>
38
38
<span class="star-count" id="star-count">{{ .StarCount }}</span>
39
39
</button>
40
40
</div>
···
81
81
<h3>Pull this image</h3>
82
82
{{ if .Tags }}
83
83
{{ $firstTag := index .Tags 0 }}
84
-
<div class="push-command">
85
-
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}</code>
86
-
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ $firstTag.Tag.Tag }}')">
87
-
Copy
88
-
</button>
89
-
</div>
84
+
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
90
85
{{ else }}
91
-
<div class="push-command">
92
-
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest</code>
93
-
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:latest')">
94
-
Copy
95
-
</button>
96
-
</div>
86
+
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
97
87
{{ end }}
98
88
</div>
99
89
</div>
···
137
127
hx-confirm="Delete tag {{ .Tag.Tag }}?"
138
128
hx-target="#tag-{{ .Tag.Tag }}"
139
129
hx-swap="outerHTML">
140
-
🗑️
130
+
<i data-lucide="trash-2"></i>
141
131
</button>
142
132
{{ end }}
143
133
</div>
···
146
136
<div style="display: flex; justify-content: space-between; align-items: center;">
147
137
<div class="digest-container">
148
138
<code class="digest" title="{{ .Tag.Digest }}">{{ .Tag.Digest }}</code>
149
-
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')">📋</button>
139
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Tag.Digest }}')"><i data-lucide="copy"></i></button>
150
140
</div>
151
141
{{ if .Platforms }}
152
142
<div class="platforms-inline">
···
157
147
{{ end }}
158
148
</div>
159
149
</div>
160
-
<div class="push-command">
161
-
<code class="pull-command">docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}</code>
162
-
<button class="copy-btn" onclick="copyToClipboard('docker pull {{ $.RegistryURL }}/{{ $.Owner.Handle }}/{{ $.Repository.Name }}:{{ .Tag.Tag }}')">
163
-
Copy
164
-
</button>
165
-
</div>
150
+
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
166
151
</div>
167
152
{{ end }}
168
153
</div>
···
187
172
<div class="manifest-item-header">
188
173
<div>
189
174
{{ if .IsManifestList }}
190
-
<span class="manifest-type">📦 Multi-arch</span>
175
+
<span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span>
191
176
{{ else }}
192
-
<span class="manifest-type">📄 Image</span>
177
+
<span class="manifest-type"><i data-lucide="file-text"></i> Image</span>
193
178
{{ end }}
194
179
{{ if .Pending }}
195
180
<span class="checking-badge"
196
181
hx-get="/api/manifest-health?endpoint={{ .Manifest.HoldEndpoint | urlquery }}"
197
182
hx-trigger="load delay:2s"
198
183
hx-swap="outerHTML">
199
-
🔄 Checking...
184
+
<i data-lucide="rotate-cw"></i> Checking...
200
185
</span>
201
186
{{ else if not .Reachable }}
202
-
<span class="offline-badge">⚠️ Offline</span>
187
+
<span class="offline-badge"><i data-lucide="alert-triangle"></i> Offline</span>
203
188
{{ end }}
204
189
<div class="digest-container">
205
190
<code class="digest manifest-digest" title="{{ .Manifest.Digest }}">{{ .Manifest.Digest }}</code>
206
-
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')">📋</button>
191
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Manifest.Digest }}')"><i data-lucide="copy"></i></button>
207
192
</div>
208
193
</div>
209
194
<div style="display: flex; gap: 1rem; align-items: center;">
···
213
198
{{ if $.IsOwner }}
214
199
<button class="delete-btn"
215
200
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')">
216
-
🗑️
201
+
<i data-lucide="trash-2"></i>
217
202
</button>
218
203
{{ end }}
219
204
</div>
+21
-10
pkg/appview/templates/pages/settings.html
+21
-10
pkg/appview/templates/pages/settings.html
···
65
65
<h3>First Time Setup</h3>
66
66
<ol>
67
67
<li>Install credential helper:
68
-
<pre><code>brew install atcr-credential-helper</code></pre>
69
-
(or download from releases)
68
+
<pre><code>curl -fsSL atcr.io/static/install.sh | bash</code></pre>
70
69
</li>
71
70
<li>Configure Docker to use the helper. Add to <code>~/.docker/config.json</code>:
72
71
<pre><code>{
···
76
75
}</code></pre>
77
76
</li>
78
77
<li>Run any Docker command:
79
-
<pre><code>docker pull {{ .RegistryURL }}/{{ .Profile.Handle }}/myimage</code></pre>
78
+
{{ template "docker-command" (print "docker pull " .RegistryURL "/" .Profile.Handle "/myimage") }}
80
79
</li>
81
80
<li>Browser will open for authorization - click Approve</li>
82
81
<li>Done! Device is automatically authorized</li>
···
205
204
.devices-section .setup-instructions {
206
205
margin: 1rem 0;
207
206
padding: 1.5rem;
208
-
background: #e3f2fd;
207
+
background: var(--code-bg);
209
208
border-radius: 4px;
210
209
}
211
210
.devices-section .setup-instructions h3 {
···
218
217
margin-bottom: 1rem;
219
218
}
220
219
.devices-section .setup-instructions pre {
221
-
background: #263238;
222
-
color: #aed581;
220
+
background: var(--bg);
221
+
color: var(--fg);
222
+
border: 1px solid var(--border);
223
223
padding: 0.75rem;
224
224
border-radius: 4px;
225
225
overflow-x: auto;
···
231
231
.devices-section .fallback-note {
232
232
margin-top: 1rem;
233
233
padding: 1rem;
234
-
background: #fff3cd;
235
-
border: 1px solid #ffc107;
234
+
background: var(--warning-bg);
235
+
border: 1px solid var(--warning);
236
236
border-radius: 4px;
237
237
}
238
+
.devices-section .fallback-note a {
239
+
color: var(--warning);
240
+
text-decoration: underline;
241
+
font-weight: 500;
242
+
}
243
+
.devices-section .fallback-note a:hover {
244
+
color: var(--primary);
245
+
}
246
+
.devices-section .fallback-note a:visited {
247
+
color: var(--warning);
248
+
}
238
249
.devices-section table {
239
250
width: 100%;
240
251
border-collapse: collapse;
···
244
255
.devices-section td {
245
256
padding: 0.75rem;
246
257
text-align: left;
247
-
border-bottom: 1px solid #ddd;
258
+
border-bottom: 1px solid var(--border);
248
259
}
249
260
.devices-section th {
250
-
background: #f5f5f5;
261
+
background: var(--code-bg);
251
262
font-weight: bold;
252
263
}
253
264
.devices-section .btn-danger {
+3
-4
pkg/appview/templates/partials/push-list.html
+3
-4
pkg/appview/templates/partials/push-list.html
···
17
17
</div>
18
18
<div class="push-stats">
19
19
<span class="push-stat">
20
-
<span class="star-icon">★</span>
20
+
<i data-lucide="star" class="star-icon{{ if .IsStarred }} star-filled{{ end }}"></i>
21
21
<span class="stat-count">{{ .StarCount }}</span>
22
22
</span>
23
23
<span class="push-stat">
24
-
<span class="pull-icon">↓</span>
24
+
<i data-lucide="arrow-down-to-line" class="pull-icon"></i>
25
25
<span class="stat-count">{{ .PullCount }}</span>
26
26
</span>
27
27
</div>
···
35
35
<div class="push-details">
36
36
<div class="digest-container">
37
37
<code class="digest" title="{{ .Digest }}">{{ .Digest }}</code>
38
-
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')">📋</button>
38
+
<button class="digest-copy-btn" onclick="copyToClipboard('{{ .Digest }}')"><i data-lucide="copy"></i></button>
39
39
</div>
40
-
<span class="separator">•</span>
41
40
<time class="timestamp" datetime="{{ .CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
42
41
{{ timeAgo .CreatedAt }}
43
42
</time>