Files
at-container-registry/pkg/appview/src/js/app.js
T

805 lines
27 KiB
JavaScript

// Theme management (system / light / dark)
function getThemePreference() {
return localStorage.getItem('theme') || 'system';
}
function getEffectiveTheme(pref) {
if (pref === 'dark' || pref === 'light') return pref;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function applyTheme() {
const pref = getThemePreference();
const effective = getEffectiveTheme(pref);
const dark = effective === 'dark';
document.documentElement.classList.toggle('dark', dark);
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
updateThemeUI(pref);
}
function setTheme(theme) {
localStorage.setItem('theme', theme);
applyTheme();
closeThemeDropdown();
}
function updateThemeUI(pref) {
// Update nav button icon to show selected preference (supports multiple toggles)
const iconMap = { system: 'sun-moon', light: 'sun', dark: 'moon' };
document.querySelectorAll('[data-theme-icon] use').forEach(use => {
use.setAttribute('href', `/icons.svg#${iconMap[pref] || 'sun-moon'}`);
});
// Update checkmarks in dropdown
document.querySelectorAll('.theme-option').forEach(option => {
const isSelected = option.dataset.value === pref;
const check = option.querySelector('.theme-check');
if (check) {
check.style.visibility = isSelected ? 'visible' : 'hidden';
}
});
}
function closeThemeDropdown() {
// Close all theme dropdowns (supports multiple toggles)
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
const details = btn.closest('details');
if (details) details.removeAttribute('open');
});
}
// Listen for system theme changes
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (getThemePreference() === 'system') {
applyTheme();
}
});
// Expandable search
function toggleSearch() {
const wrapper = document.querySelector('.nav-search-wrapper');
const input = document.getElementById('nav-search-input');
if (!wrapper || !input) return;
wrapper.classList.toggle('expanded');
if (wrapper.classList.contains('expanded')) {
input.focus();
}
}
function closeSearch() {
const wrapper = document.querySelector('.nav-search-wrapper');
if (wrapper) {
wrapper.classList.remove('expanded');
}
}
// Close search on Escape key and click outside
document.addEventListener('DOMContentLoaded', () => {
const wrapper = document.querySelector('.nav-search-wrapper');
const input = document.getElementById('nav-search-input');
if (!wrapper || !input) return;
// Close on Escape key, open on "/" key (GitHub-style)
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && wrapper.classList.contains('expanded')) {
closeSearch();
}
if (e.key === '/' && !wrapper.classList.contains('expanded')) {
const tag = e.target.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || e.target.isContentEditable) return;
e.preventDefault();
wrapper.classList.add('expanded');
input.focus();
}
});
// Close on click outside
document.addEventListener('click', (e) => {
if (wrapper.classList.contains('expanded') &&
!wrapper.contains(e.target)) {
closeSearch();
}
});
});
// Copy to clipboard
// text: the text to copy
// btn: optional button element for feedback (uses event.target if not provided)
function copyToClipboard(text, btn) {
if (!btn && typeof event !== 'undefined') {
btn = event.target.closest('button');
}
navigator.clipboard.writeText(text).then(() => {
if (!btn) return;
// Show success feedback
const originalHTML = btn.innerHTML;
btn.innerHTML = '<svg class="icon size-4" aria-hidden="true"><use href="/icons.svg#check"></use></svg> Copied!';
setTimeout(() => {
btn.innerHTML = originalHTML;
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
// Initialize copy buttons with data-cmd attribute and clickable cards with data-href
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('click', (e) => {
// Handle copy buttons
const btn = e.target.closest('button[data-cmd]');
if (btn) {
copyToClipboard(btn.getAttribute('data-cmd'), btn);
return;
}
// Handle clickable cards (skip if clicking on interactive elements)
if (e.target.closest('a, button, input, .cmd')) return;
const card = e.target.closest('[data-href]');
if (card) {
window.location = card.getAttribute('data-href');
}
});
});
// Time ago helper (for client-side rendering)
function timeAgo(date) {
const seconds = Math.floor((new Date() - new Date(date)) / 1000);
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60,
second: 1
};
for (const [name, secondsInInterval] of Object.entries(intervals)) {
const interval = Math.floor(seconds / secondsInInterval);
if (interval >= 1) {
return interval === 1 ? `1 ${name} ago` : `${interval} ${name}s ago`;
}
}
return 'just now';
}
// Update timestamps on page load and HTMX swaps
function updateTimestamps() {
document.querySelectorAll('time[datetime]').forEach(el => {
const date = el.getAttribute('datetime');
if (date && !el.dataset.noUpdate) {
const ago = timeAgo(date);
if (el.textContent !== ago) {
el.textContent = ago;
}
}
});
}
// Initial timestamp update and theme setup
document.addEventListener('DOMContentLoaded', () => {
updateTimestamps();
applyTheme();
// Theme dropdown setup - DaisyUI details handles open/close natively
// Supports multiple theme menus (e.g., mobile + desktop nav)
document.querySelectorAll('[data-theme-menu]').forEach(themeMenu => {
// Handle theme option clicks
themeMenu.querySelectorAll('.theme-option').forEach(option => {
option.addEventListener('click', () => {
setTheme(option.dataset.value);
});
});
});
// Close dropdowns when clicking outside
document.addEventListener('click', (e) => {
const clickedDropdown = e.target.closest('details.dropdown');
document.querySelectorAll('details.dropdown[open]').forEach(details => {
if (details !== clickedDropdown) {
details.removeAttribute('open');
}
});
});
});
// Update timestamps after HTMX swaps
document.addEventListener('htmx:afterSwap', updateTimestamps);
// Update timestamps periodically
setInterval(updateTimestamps, 60000); // Every minute
// Toggle offline manifests visibility
function toggleOfflineManifests() {
const checkbox = document.getElementById('show-offline-toggle');
const manifestsList = document.querySelector('.manifests-list');
if (!checkbox || !manifestsList) return;
// Store preference in localStorage
localStorage.setItem('showOfflineManifests', checkbox.checked);
// Toggle visibility of offline manifests
if (checkbox.checked) {
manifestsList.classList.add('show-offline');
} else {
manifestsList.classList.remove('show-offline');
}
}
// Restore offline manifests toggle state on page load
document.addEventListener('DOMContentLoaded', () => {
const checkbox = document.getElementById('show-offline-toggle');
if (!checkbox) return;
// Restore state from localStorage
const showOffline = localStorage.getItem('showOfflineManifests') === 'true';
checkbox.checked = showOffline;
// Apply initial state
const manifestsList = document.querySelector('.manifests-list');
if (manifestsList) {
if (showOffline) {
manifestsList.classList.add('show-offline');
} else {
manifestsList.classList.remove('show-offline');
}
}
});
// Delete manifest with confirmation for tagged manifests
async function deleteManifest(repository, digest, sanitizedId) {
try {
// First, try to delete without confirmation
const response = await fetch('/api/manifests', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: repository, digest: digest, confirm: false }),
});
if (response.status === 409) {
// Manifest has tags, need confirmation
const data = await response.json();
showManifestDeleteModal(repository, digest, sanitizedId, data.tags);
} else if (response.ok) {
// Successfully deleted
removeManifestElement(sanitizedId);
} else {
// Other error
const errorText = await response.text();
alert(`Failed to delete manifest: ${errorText}`);
}
} catch (err) {
console.error('Error deleting manifest:', err);
alert(`Error deleting manifest: ${err.message}`);
}
}
// Show the confirmation modal for deleting a tagged manifest
function showManifestDeleteModal(repository, digest, sanitizedId, tags) {
const modal = document.getElementById('manifest-delete-modal');
const tagsList = document.getElementById('manifest-delete-tags');
const confirmBtn = document.getElementById('confirm-manifest-delete-btn');
// Clear and populate tags list
tagsList.innerHTML = '';
tags.forEach(tag => {
const li = document.createElement('li');
li.textContent = tag;
tagsList.appendChild(li);
});
// Set up confirm button click handler
confirmBtn.onclick = () => confirmManifestDelete(repository, digest, sanitizedId);
// Show modal
modal.style.display = 'flex';
}
// Close the manifest delete confirmation modal
function closeManifestDeleteModal() {
const modal = document.getElementById('manifest-delete-modal');
modal.style.display = 'none';
}
// Confirm and execute manifest deletion with all tags
async function confirmManifestDelete(repository, digest, sanitizedId) {
const confirmBtn = document.getElementById('confirm-manifest-delete-btn');
const originalText = confirmBtn.textContent;
try {
// Disable button and show loading state
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
// Delete with confirmation
const response = await fetch('/api/manifests', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: repository, digest: digest, confirm: true }),
});
if (response.ok) {
// Successfully deleted
closeManifestDeleteModal();
removeManifestElement(sanitizedId);
// Also remove any tag elements that were deleted
location.reload(); // Reload to refresh the tags list
} else {
// Error
const errorText = await response.text();
alert(`Failed to delete manifest: ${errorText}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
} catch (err) {
console.error('Error deleting manifest:', err);
alert(`Error deleting manifest: ${err.message}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
// Delete all untagged manifests in a repository
async function deleteUntaggedManifests(repository) {
const confirmBtn = document.getElementById('confirm-untagged-delete-btn');
const originalText = confirmBtn.textContent;
try {
confirmBtn.disabled = true;
confirmBtn.textContent = 'Deleting...';
const response = await fetch('/api/manifests/untagged', {
method: 'DELETE',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ repo: repository }),
});
const data = await response.json();
if (response.ok) {
document.getElementById('untagged-delete-modal').close();
showToast(`Deleted ${data.deleted} untagged manifest(s)`, 'success');
if (data.deleted > 0) {
location.reload();
}
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
} else {
alert(`Failed to delete untagged manifests: ${data.error || 'Unknown error'}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
} catch (err) {
console.error('Error deleting untagged manifests:', err);
alert(`Error: ${err.message}`);
confirmBtn.disabled = false;
confirmBtn.textContent = originalText;
}
}
// Remove a manifest element from the DOM
function removeManifestElement(sanitizedId) {
const element = document.getElementById(`manifest-${sanitizedId}`);
if (element) {
element.remove();
}
}
// Close modal when clicking outside
document.addEventListener('DOMContentLoaded', () => {
const modal = document.getElementById('manifest-delete-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeManifestDeleteModal();
}
});
}
});
// Vulnerability details modal
async function openVulnDetails(digest, holdEndpoint) {
const modal = document.getElementById('vuln-detail-modal');
const body = document.getElementById('vuln-modal-body');
if (!modal || !body) return;
// Show modal with loading spinner
body.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-lg"></span></div>';
modal.showModal();
try {
const resp = await fetch(`/api/vuln-details?digest=${encodeURIComponent(digest)}&holdEndpoint=${encodeURIComponent(holdEndpoint)}`);
body.innerHTML = await resp.text();
} catch {
body.innerHTML = '<p class="text-error">Failed to load vulnerability details</p>';
}
}
// Login page recent accounts helper (works alongside actor-typeahead web component)
class RecentAccountsHelper {
constructor(inputElement) {
this.input = inputElement;
this.typeahead = inputElement.closest('actor-typeahead');
this.dropdown = null;
this.currentFocus = -1;
this.typeaheadClosed = false; // Track when typeahead closes after selection
this.init();
}
init() {
this.createDropdown();
// Show recent accounts on focus when input is empty
this.input.addEventListener('focus', () => this.handleFocus());
// Hide recent accounts when user starts typing (actor-typeahead takes over)
this.input.addEventListener('input', () => this.handleInput());
// Keyboard navigation for recent accounts dropdown
this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.input.contains(e.target) && !this.dropdown.contains(e.target)) {
this.hideDropdown();
}
});
}
createDropdown() {
this.dropdown = document.createElement('div');
this.dropdown.className = 'recent-accounts-dropdown';
this.dropdown.style.display = 'none';
// Insert after the actor-typeahead element
if (this.typeahead) {
this.typeahead.insertAdjacentElement('afterend', this.dropdown);
} else {
this.input.insertAdjacentElement('afterend', this.dropdown);
}
}
handleFocus() {
const value = this.input.value.trim();
if (value.length < 1) {
this.showRecentAccounts();
}
}
handleInput() {
const value = this.input.value.trim();
// Hide recent accounts once user starts typing (actor-typeahead shows its menu at 2+ chars)
if (value.length >= 1) {
this.hideDropdown();
}
// Reset typeahead closed flag when user types (re-enable Tab navigation)
this.typeaheadClosed = false;
}
showRecentAccounts() {
const recent = this.getRecentAccounts();
if (recent.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
const header = document.createElement('div');
header.className = 'recent-accounts-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
recent.forEach((handle, index) => {
const item = document.createElement('div');
item.className = 'recent-accounts-item';
item.dataset.index = index;
item.dataset.handle = handle;
item.textContent = handle;
item.addEventListener('click', () => this.selectItem(handle));
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
selectItem(handle) {
this.input.value = handle;
this.hideDropdown();
this.input.focus();
}
hideDropdown() {
this.dropdown.style.display = 'none';
this.currentFocus = -1;
}
handleKeydown(e) {
// Track Enter key to detect typeahead selection (menu closes after)
if (e.key === 'Enter') {
const value = this.input.value.trim();
if (value.length >= 2) {
this.typeaheadClosed = true;
}
}
// Handle Tab for actor-typeahead navigation
// When input has 2+ chars and typeahead hasn't been closed by selection
if (e.key === 'Tab') {
const value = this.input.value.trim();
if (value.length >= 2 && !this.typeaheadClosed) {
e.preventDefault();
// Dispatch synthetic arrow key event to navigate typeahead
const arrowKey = e.shiftKey ? 'ArrowUp' : 'ArrowDown';
const syntheticEvent = new KeyboardEvent('keydown', {
key: arrowKey,
bubbles: true,
cancelable: true
});
this.typeahead.dispatchEvent(syntheticEvent);
return;
}
}
if (this.dropdown.style.display === 'none') return;
const items = this.dropdown.querySelectorAll('.recent-accounts-item');
if (e.key === 'ArrowDown') {
e.preventDefault();
this.currentFocus++;
if (this.currentFocus >= items.length) this.currentFocus = 0;
this.updateFocus(items);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.currentFocus--;
if (this.currentFocus < 0) this.currentFocus = items.length - 1;
this.updateFocus(items);
} else if (e.key === 'Enter' && this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
this.selectItem(items[this.currentFocus].dataset.handle);
} else if (e.key === 'Escape') {
this.hideDropdown();
}
}
updateFocus(items) {
items.forEach((item, index) => {
item.classList.toggle('focused', index === this.currentFocus);
});
}
getRecentAccounts() {
try {
const recent = localStorage.getItem('atcr_recent_handles');
return recent ? JSON.parse(recent) : [];
} catch (_) {
return [];
}
}
saveRecentAccount(handle) {
if (!handle) return;
try {
let recent = this.getRecentAccounts();
recent = recent.filter(h => h !== handle);
recent.unshift(handle);
recent = recent.slice(0, 5);
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
}
}
// Initialize recent accounts helper on login page
document.addEventListener('DOMContentLoaded', () => {
const loginForm = document.getElementById('login-form');
const handleInput = document.getElementById('handle');
if (loginForm && handleInput) {
new RecentAccountsHelper(handleInput);
}
});
// Save successful login handle from cookie (set by server after OAuth success)
document.addEventListener('DOMContentLoaded', () => {
const cookie = document.cookie.split('; ').find(c => c.startsWith('atcr_login_handle='));
if (!cookie) return;
const handle = decodeURIComponent(cookie.split('=')[1]);
if (handle) {
// Save to recent accounts
try {
const key = 'atcr_recent_handles';
let recent = JSON.parse(localStorage.getItem(key) || '[]');
recent = recent.filter(h => h !== handle);
recent.unshift(handle);
recent = recent.slice(0, 5);
localStorage.setItem(key, JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
// Delete the cookie
document.cookie = 'atcr_login_handle=; path=/; max-age=0';
}
});
// Featured carousel - scroll-based with proper wrap-around
// Deferred initialization to avoid blocking main thread during page load
function initFeaturedCarousel() {
const carousel = document.getElementById('featured-carousel');
const prevBtn = document.getElementById('carousel-prev');
const nextBtn = document.getElementById('carousel-next');
if (!carousel) return;
const items = Array.from(carousel.querySelectorAll('.carousel-item'));
if (items.length === 0) return;
let intervalId = null;
const intervalMs = 5000;
// Cache dimensions to avoid forced reflow on every navigation
let cachedItemWidth = 0;
let cachedContainerWidth = 0;
let cachedScrollWidth = 0;
function updateCachedDimensions() {
const item = items[0];
if (!item) return;
// Batch all geometric reads together to minimize reflow
const style = getComputedStyle(carousel);
const gap = parseFloat(style.gap) || 24;
cachedItemWidth = item.offsetWidth + gap;
cachedContainerWidth = carousel.offsetWidth;
cachedScrollWidth = carousel.scrollWidth;
}
// Initial measurement after layout is stable (double-rAF)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
updateCachedDimensions();
startInterval();
});
});
// Update on resize (debounced to avoid excessive recalculation)
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(updateCachedDimensions, 150);
});
function getItemWidth() {
if (!cachedItemWidth) updateCachedDimensions();
return cachedItemWidth;
}
function getVisibleCount() {
if (!cachedContainerWidth || !cachedItemWidth) updateCachedDimensions();
return Math.round(cachedContainerWidth / cachedItemWidth) || 1;
}
function getMaxScroll() {
if (!cachedScrollWidth || !cachedContainerWidth) updateCachedDimensions();
return cachedScrollWidth - cachedContainerWidth;
}
function advance() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the end, wrap to start
if (currentScroll >= maxScroll - 10) {
carousel.scrollTo({ left: 0, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll + itemWidth, behavior: 'smooth' });
}
}
function retreat() {
const itemWidth = getItemWidth();
const maxScroll = getMaxScroll();
const currentScroll = carousel.scrollLeft;
// If we're at or near the start, wrap to end
if (currentScroll <= 10) {
carousel.scrollTo({ left: maxScroll, behavior: 'smooth' });
} else {
carousel.scrollTo({ left: currentScroll - itemWidth, behavior: 'smooth' });
}
}
if (prevBtn) prevBtn.addEventListener('click', () => { stopInterval(); retreat(); startInterval(); });
if (nextBtn) nextBtn.addEventListener('click', () => { stopInterval(); advance(); startInterval(); });
function startInterval() {
if (intervalId || items.length <= getVisibleCount()) return;
intervalId = setInterval(advance, intervalMs);
}
function stopInterval() {
if (intervalId) { clearInterval(intervalId); intervalId = null; }
}
carousel.addEventListener('mouseenter', stopInterval);
carousel.addEventListener('mouseleave', startInterval);
}
// Defer carousel setup to after critical path
document.addEventListener('DOMContentLoaded', () => {
if ('requestIdleCallback' in window) {
requestIdleCallback(initFeaturedCarousel, { timeout: 2000 });
} else {
setTimeout(initFeaturedCarousel, 100);
}
});
// Toast notifications (auto-dismiss after 3s)
function showToast(message, type) {
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'toast toast-end toast-bottom z-50';
document.body.appendChild(container);
}
const alertClass = type === 'error' ? 'alert-error' : 'alert-success';
const toast = document.createElement('div');
toast.className = `alert ${alertClass} shadow-lg transition-opacity duration-300`;
toast.innerHTML = `<span>${message}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// Test webhook via fetch + toast
async function testWebhook(id) {
try {
const resp = await fetch(`/api/webhooks/${id}/test`, {
method: 'POST',
credentials: 'include',
});
const text = await resp.text();
if (text.includes('class="success"') || (resp.ok && !text.includes('class="error"'))) {
showToast('Test webhook delivered successfully!', 'success');
} else {
showToast('Test delivery failed \u2014 check the webhook URL', 'error');
}
} catch {
showToast('Failed to reach server', 'error');
}
}
// Export functions that are called from templates via onclick handlers
window.setTheme = setTheme;
window.toggleSearch = toggleSearch;
window.closeSearch = closeSearch;
window.copyToClipboard = copyToClipboard;
window.toggleOfflineManifests = toggleOfflineManifests;
window.deleteManifest = deleteManifest;
window.deleteUntaggedManifests = deleteUntaggedManifests;
window.closeManifestDeleteModal = closeManifestDeleteModal;
window.openVulnDetails = openVulnDetails;
window.showToast = showToast;
window.testWebhook = testWebhook;