mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-31 05:07:09 +00:00
393 lines
19 KiB
HTML
393 lines
19 KiB
HTML
{{ define "repository" }}
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<title>{{ if .Repository.Title }}{{ .Repository.Title }}{{ else }}{{ .Owner.Handle }}/{{ .Repository.Name }}{{ end }} - ATCR</title>
|
|
<!-- Open Graph -->
|
|
<meta property="og:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
|
|
<meta property="og:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
|
|
<meta property="og:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
|
<meta property="og:image:width" content="1200">
|
|
<meta property="og:image:height" content="630">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://{{ .RegistryURL }}/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
|
<meta property="og:site_name" content="ATCR">
|
|
<!-- Twitter Card (used by Discord) -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:title" content="{{ .Owner.Handle }}/{{ .Repository.Name }} - ATCR">
|
|
<meta name="twitter:description" content="{{ if .Repository.Description }}{{ .Repository.Description }}{{ else }}Container image on ATCR{{ end }}">
|
|
<meta name="twitter:image" content="https://{{ .RegistryURL }}/og/r/{{ .Owner.Handle }}/{{ .Repository.Name }}">
|
|
{{ template "head" . }}
|
|
</head>
|
|
<body>
|
|
{{ template "nav" . }}
|
|
|
|
<main class="container">
|
|
<div class="repository-page">
|
|
<!-- Repository Header -->
|
|
<div class="repository-header">
|
|
<div class="repo-hero">
|
|
<div class="repo-hero-icon-wrapper">
|
|
{{ if .Repository.IconURL }}
|
|
<img src="{{ .Repository.IconURL }}" alt="{{ .Repository.Name }}" class="repo-hero-icon">
|
|
{{ else }}
|
|
<div class="repo-hero-icon-placeholder">{{ firstChar .Repository.Name }}</div>
|
|
{{ end }}
|
|
{{ if $.IsOwner }}
|
|
<label class="avatar-upload-overlay" for="avatar-upload">
|
|
<i data-lucide="plus"></i>
|
|
</label>
|
|
<input type="file" id="avatar-upload" accept="image/png,image/jpeg,image/webp"
|
|
onchange="uploadAvatar(this, '{{ .Repository.Name }}')" hidden>
|
|
{{ end }}
|
|
</div>
|
|
<div class="repo-hero-info">
|
|
<h1>
|
|
<a href="/u/{{ .Owner.Handle }}" class="owner-link">{{ .Owner.Handle }}</a>
|
|
<span class="repo-separator">/</span>
|
|
<span class="repo-name">{{ .Repository.Name }}</span>
|
|
</h1>
|
|
{{ if .Repository.Description }}
|
|
<p class="repo-hero-description">{{ .Repository.Description }}</p>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Star Button and Metadata Row -->
|
|
<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 }}')">
|
|
<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>
|
|
|
|
<!-- Metadata Section -->
|
|
{{ if or .Repository.Licenses .Repository.SourceURL .Repository.DocumentationURL .Repository.Version }}
|
|
<div class="repo-metadata">
|
|
{{ if .Repository.Version }}
|
|
<span class="metadata-badge version-badge" title="Version">
|
|
{{ .Repository.Version }}
|
|
</span>
|
|
{{ end }}
|
|
{{ if .Repository.Licenses }}
|
|
{{ range parseLicenses .Repository.Licenses }}
|
|
{{ if .IsValid }}
|
|
<a href="{{ .URL }}" target="_blank" rel="noopener noreferrer" class="metadata-badge license-badge" title="{{ .Name }}">
|
|
{{ .SPDXID }}
|
|
</a>
|
|
{{ else }}
|
|
<span class="metadata-badge license-badge" title="Custom license: {{ .Name }}">
|
|
{{ .Name }}
|
|
</span>
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ end }}
|
|
{{ if .Repository.SourceURL }}
|
|
<a href="{{ .Repository.SourceURL }}" target="_blank" class="metadata-link">
|
|
Source
|
|
</a>
|
|
{{ end }}
|
|
{{ if .Repository.DocumentationURL }}
|
|
<a href="{{ .Repository.DocumentationURL }}" target="_blank" class="metadata-link">
|
|
Documentation
|
|
</a>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<div class="repo-metadata"></div>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<!-- Pull Command -->
|
|
<div class="pull-command-section">
|
|
<h3>Pull this image</h3>
|
|
{{ if .Tags }}
|
|
{{ $firstTag := index .Tags 0 }}
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" $firstTag.Tag.Tag) }}
|
|
{{ else }}
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":latest") }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- README and Tags/Manifests Layout -->
|
|
{{ if .ReadmeHTML }}
|
|
<div class="repo-content-layout">
|
|
<!-- README Section (Left) -->
|
|
<div class="readme-section">
|
|
<h2>Overview</h2>
|
|
<div class="readme-content markdown-body">
|
|
{{ .ReadmeHTML }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tags and Manifests (Right) -->
|
|
<div class="repo-sidebar">
|
|
{{ end }}
|
|
|
|
<!-- Tags Section -->
|
|
<div class="repo-section">
|
|
<h2>Tags</h2>
|
|
{{ if .Tags }}
|
|
<div class="tags-list">
|
|
{{ range .Tags }}
|
|
<div class="tag-item" id="tag-{{ sanitizeID .Tag.Tag }}">
|
|
<div class="tag-item-header">
|
|
<div>
|
|
<span class="tag-name-large">{{ .Tag.Tag }}</span>
|
|
{{ if .IsMultiArch }}
|
|
<span class="badge-multi">Multi-arch</span>
|
|
{{ end }}
|
|
{{ if .HasAttestations }}
|
|
<span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</span>
|
|
{{ end }}
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; align-items: center;">
|
|
<time class="tag-timestamp" datetime="{{ .Tag.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
|
{{ timeAgo .Tag.CreatedAt }}
|
|
</time>
|
|
{{ if $.IsOwner }}
|
|
<button class="delete-btn"
|
|
hx-delete="/api/images/{{ $.Repository.Name }}/tags/{{ .Tag.Tag }}"
|
|
hx-confirm="Delete tag {{ .Tag.Tag }}?"
|
|
hx-target="#tag-{{ sanitizeID .Tag.Tag }}"
|
|
hx-swap="outerHTML">
|
|
<i data-lucide="trash-2"></i>
|
|
</button>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
<div class="tag-item-details">
|
|
<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 }}')"><i data-lucide="copy"></i></button>
|
|
</div>
|
|
{{ if .Platforms }}
|
|
<div class="platforms-inline">
|
|
{{ range .Platforms }}
|
|
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
{{ template "docker-command" (print "docker pull " $.RegistryURL "/" $.Owner.Handle "/" $.Repository.Name ":" .Tag.Tag) }}
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<p class="empty-message">No tags available</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
<!-- Manifests Section -->
|
|
<div class="repo-section">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h2>Manifests</h2>
|
|
<label class="show-offline-toggle">
|
|
<input type="checkbox" id="show-offline-toggle" onchange="toggleOfflineManifests()">
|
|
<span>Show offline images</span>
|
|
</label>
|
|
</div>
|
|
{{ if .Manifests }}
|
|
<div class="manifests-list">
|
|
{{ range .Manifests }}
|
|
<div class="manifest-item" id="manifest-{{ sanitizeID .Manifest.Digest }}" data-reachable="{{ .Reachable }}">
|
|
<div class="manifest-item-header">
|
|
<div>
|
|
{{ if .IsManifestList }}
|
|
<span class="manifest-type"><i data-lucide="package"></i> Multi-arch</span>
|
|
{{ else }}
|
|
<span class="manifest-type"><i data-lucide="file-text"></i> Image</span>
|
|
{{ end }}
|
|
{{ if .HasAttestations }}
|
|
<span class="badge-attestation"><i data-lucide="shield-check"></i> Attestations</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">
|
|
<i data-lucide="refresh-ccw"></i> Checking...
|
|
</span>
|
|
{{ else if not .Reachable }}
|
|
<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 }}')"><i data-lucide="copy"></i></button>
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 1rem; align-items: center;">
|
|
<time datetime="{{ .Manifest.CreatedAt.Format "2006-01-02T15:04:05Z07:00" }}">
|
|
{{ timeAgo .Manifest.CreatedAt }}
|
|
</time>
|
|
{{ if $.IsOwner }}
|
|
<button class="delete-btn"
|
|
onclick="deleteManifest('{{ $.Repository.Name }}', '{{ .Manifest.Digest }}', '{{ sanitizeID .Manifest.Digest }}')">
|
|
<i data-lucide="trash-2"></i>
|
|
</button>
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
<div class="manifest-item-details">
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
<div>
|
|
{{ if .Tags }}
|
|
<span class="manifest-detail-label">Tags:</span>
|
|
{{ range $index, $tag := .Tags }}{{ if $index }}, {{ end }}{{ $tag }}{{ end }}
|
|
{{ else }}
|
|
<span class="text-muted">(untagged)</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ if .IsManifestList }}
|
|
{{ if .Platforms }}
|
|
<div class="platforms-inline">
|
|
{{ range .Platforms }}
|
|
<span class="platform-badge">{{ .OS }}/{{ .Architecture }}{{ if .Variant }}/{{ .Variant }}{{ end }}</span>
|
|
{{ end }}
|
|
</div>
|
|
{{ end }}
|
|
{{ end }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{{ end }}
|
|
</div>
|
|
{{ else }}
|
|
<p class="empty-message">No manifests available</p>
|
|
{{ end }}
|
|
</div>
|
|
|
|
{{ if .ReadmeHTML }}
|
|
</div><!-- Close repo-sidebar -->
|
|
</div><!-- Close repo-content-layout -->
|
|
{{ end }}
|
|
</div>
|
|
</main>
|
|
|
|
<!-- Modal container for HTMX -->
|
|
<div id="modal"></div>
|
|
|
|
<!-- Manifest Delete Confirmation Modal -->
|
|
<div id="manifest-delete-modal" class="modal-overlay" style="display: none;">
|
|
<div class="modal-dialog">
|
|
<div class="modal-header">
|
|
<h3>Confirm Deletion</h3>
|
|
<button class="modal-close" onclick="closeManifestDeleteModal()">×</button>
|
|
</div>
|
|
<div class="modal-body">
|
|
<p id="manifest-delete-message">This manifest has associated tags that will also be deleted:</p>
|
|
<ul id="manifest-delete-tags" class="tag-list"></ul>
|
|
<p><strong>This action cannot be undone.</strong></p>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button class="btn btn-secondary" onclick="closeManifestDeleteModal()">Cancel</button>
|
|
<button class="btn btn-danger" id="confirm-manifest-delete-btn">Delete All</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.modal-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 1000;
|
|
}
|
|
|
|
.modal-dialog {
|
|
background: var(--bg-secondary, #1a1a1a);
|
|
border: 1px solid var(--border-color, #333);
|
|
border-radius: 8px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
.modal-header {
|
|
padding: 1rem 1.5rem;
|
|
border-bottom: 1px solid var(--border-color, #333);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.modal-header h3 {
|
|
margin: 0;
|
|
font-size: 1.25rem;
|
|
}
|
|
|
|
.modal-close {
|
|
background: none;
|
|
border: none;
|
|
font-size: 1.5rem;
|
|
cursor: pointer;
|
|
color: var(--text-color, #fff);
|
|
padding: 0;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
line-height: 1;
|
|
}
|
|
|
|
.modal-body {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.modal-body .tag-list {
|
|
margin: 1rem 0;
|
|
padding-left: 1.5rem;
|
|
}
|
|
|
|
.modal-body .tag-list li {
|
|
margin: 0.5rem 0;
|
|
font-family: monospace;
|
|
}
|
|
|
|
.modal-footer {
|
|
padding: 1rem 1.5rem;
|
|
border-top: 1px solid var(--border-color, #333);
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn {
|
|
padding: 0.5rem 1rem;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.btn-secondary {
|
|
background: var(--bg-tertiary, #2a2a2a);
|
|
color: var(--text-color, #fff);
|
|
}
|
|
|
|
.btn-secondary:hover {
|
|
background: var(--bg-hover, #3a3a3a);
|
|
}
|
|
|
|
.btn-danger {
|
|
background: #dc3545;
|
|
color: white;
|
|
}
|
|
|
|
.btn-danger:hover {
|
|
background: #c82333;
|
|
}
|
|
</style>
|
|
</body>
|
|
</html>
|
|
{{ end }}
|