mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 12:46:57 +00:00
805 lines
27 KiB
JavaScript
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;
|