448 lines
14 KiB
JavaScript
448 lines
14 KiB
JavaScript
// Theme management
|
|
// Load theme immediately to avoid flash
|
|
(function() {
|
|
const theme = localStorage.getItem('theme') || 'light';
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
})();
|
|
|
|
function toggleTheme() {
|
|
const html = document.documentElement;
|
|
const currentTheme = html.getAttribute('data-theme') || 'light';
|
|
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
html.setAttribute('data-theme', newTheme);
|
|
localStorage.setItem('theme', newTheme);
|
|
updateThemeIcon();
|
|
}
|
|
|
|
function updateThemeIcon() {
|
|
const themeBtn = document.getElementById('theme-toggle');
|
|
if (!themeBtn) return;
|
|
|
|
const currentTheme = document.documentElement.getAttribute('data-theme') || 'light';
|
|
const icon = themeBtn.querySelector('.theme-icon');
|
|
|
|
if (icon) {
|
|
// In dark mode, show sun icon (to switch to light)
|
|
// In light mode, show moon icon (to switch to dark)
|
|
icon.setAttribute('data-lucide', currentTheme === 'dark' ? 'sun' : 'moon');
|
|
|
|
// Re-initialize Lucide icons
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
themeBtn.setAttribute('aria-label', currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
|
|
}
|
|
|
|
// Copy to clipboard
|
|
function copyToClipboard(text) {
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
// Show success feedback
|
|
const btn = event.target.closest('button');
|
|
const originalHTML = btn.innerHTML;
|
|
btn.innerHTML = '<i data-lucide="check"></i> Copied!';
|
|
// Re-initialize Lucide icons for the new icon
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
setTimeout(() => {
|
|
btn.innerHTML = originalHTML;
|
|
// Re-initialize Lucide icons to restore original icon
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
}, 2000);
|
|
}).catch(err => {
|
|
console.error('Failed to copy:', err);
|
|
});
|
|
}
|
|
|
|
// 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
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
updateTimestamps();
|
|
updateThemeIcon();
|
|
});
|
|
|
|
// Update timestamps after HTMX swaps
|
|
document.addEventListener('htmx:afterSwap', updateTimestamps);
|
|
|
|
// Update timestamps periodically
|
|
setInterval(updateTimestamps, 60000); // Every minute
|
|
|
|
// Toggle repository details (for images page)
|
|
function toggleRepo(name) {
|
|
const details = document.getElementById('repo-' + name);
|
|
const btn = document.getElementById('btn-' + name);
|
|
|
|
if (details.style.display === 'none') {
|
|
details.style.display = 'block';
|
|
btn.innerHTML = '<i data-lucide="chevron-up"></i>';
|
|
} else {
|
|
details.style.display = 'none';
|
|
btn.innerHTML = '<i data-lucide="chevron-down"></i>';
|
|
}
|
|
|
|
// Re-initialize Lucide icons
|
|
if (typeof lucide !== 'undefined') {
|
|
lucide.createIcons();
|
|
}
|
|
}
|
|
|
|
// User dropdown menu
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const menuBtn = document.getElementById('user-menu-btn');
|
|
const dropdownMenu = document.getElementById('user-dropdown-menu');
|
|
|
|
if (menuBtn && dropdownMenu) {
|
|
// Toggle dropdown on button click
|
|
menuBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const isExpanded = menuBtn.getAttribute('aria-expanded') === 'true';
|
|
|
|
if (isExpanded) {
|
|
closeDropdown();
|
|
} else {
|
|
openDropdown();
|
|
}
|
|
});
|
|
|
|
// Close dropdown when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!menuBtn.contains(e.target) && !dropdownMenu.contains(e.target)) {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
// Close dropdown on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeDropdown();
|
|
}
|
|
});
|
|
|
|
function openDropdown() {
|
|
menuBtn.setAttribute('aria-expanded', 'true');
|
|
dropdownMenu.removeAttribute('hidden');
|
|
}
|
|
|
|
function closeDropdown() {
|
|
menuBtn.setAttribute('aria-expanded', 'false');
|
|
dropdownMenu.setAttribute('hidden', '');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Toggle star on a repository
|
|
async function toggleStar(handle, repository) {
|
|
const starBtn = document.getElementById('star-btn');
|
|
const starIcon = document.getElementById('star-icon');
|
|
const starCountEl = document.getElementById('star-count');
|
|
|
|
if (!starBtn || !starIcon || !starCountEl) return;
|
|
|
|
// Disable button during request
|
|
starBtn.disabled = true;
|
|
|
|
try {
|
|
// Check current state
|
|
const isStarred = starIcon.classList.contains('star-filled');
|
|
const method = isStarred ? 'DELETE' : 'POST';
|
|
const url = `/api/stars/${handle}/${repository}`;
|
|
|
|
const response = await fetch(url, {
|
|
method: method,
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
console.log('Not authenticated, redirecting to login');
|
|
// Not authenticated, redirect to login
|
|
window.location.href = '/auth/oauth/login';
|
|
return;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
console.error(`Toggle star failed: ${response.status} ${response.statusText}`, errorText);
|
|
throw new Error(`Failed to toggle star: ${errorText}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
// Update UI optimistically
|
|
if (data.starred) {
|
|
starIcon.classList.add('star-filled');
|
|
starBtn.classList.add('starred');
|
|
// Optimistically increment count
|
|
const currentCount = parseInt(starCountEl.textContent) || 0;
|
|
starCountEl.textContent = currentCount + 1;
|
|
} else {
|
|
starIcon.classList.remove('star-filled');
|
|
starBtn.classList.remove('starred');
|
|
// Optimistically decrement count
|
|
const currentCount = parseInt(starCountEl.textContent) || 0;
|
|
starCountEl.textContent = Math.max(0, currentCount - 1);
|
|
}
|
|
|
|
// Don't fetch count immediately - trust the optimistic update
|
|
// The actual count will be correct on next page load
|
|
|
|
} catch (err) {
|
|
console.error('Error toggling star:', err);
|
|
alert(`Failed to toggle star: ${err.message}`);
|
|
} finally {
|
|
starBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
// Load star status and count for current repository
|
|
async function loadStarStatus() {
|
|
const starBtn = document.getElementById('star-btn');
|
|
const starIcon = document.getElementById('star-icon');
|
|
|
|
if (!starBtn || !starIcon) return; // Not on repository page
|
|
|
|
// Extract handle and repository from button onclick attribute
|
|
const onclick = starBtn.getAttribute('onclick');
|
|
const match = onclick.match(/toggleStar\('([^']+)',\s*'([^']+)'\)/);
|
|
if (!match) return;
|
|
|
|
const handle = match[1];
|
|
const repository = match[2];
|
|
|
|
try {
|
|
// Check if user has starred this repo
|
|
const starResponse = await fetch(`/api/stars/${handle}/${repository}`, {
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (starResponse.ok) {
|
|
const starData = await starResponse.json();
|
|
console.log('Star status data:', starData);
|
|
if (starData.starred) {
|
|
starIcon.classList.add('star-filled');
|
|
starBtn.classList.add('starred');
|
|
}
|
|
} else {
|
|
const errorText = await starResponse.text();
|
|
console.error('Failed to load star status:', errorText);
|
|
}
|
|
|
|
// Load star count
|
|
await loadStarCount(handle, repository);
|
|
|
|
} catch (err) {
|
|
console.error('Error loading star status:', err);
|
|
}
|
|
}
|
|
|
|
// Load star count for a repository
|
|
async function loadStarCount(handle, repository) {
|
|
const starCountEl = document.getElementById('star-count');
|
|
if (!starCountEl) return;
|
|
|
|
try {
|
|
const statsResponse = await fetch(`/api/stats/${handle}/${repository}`, {
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (statsResponse.ok) {
|
|
const stats = await statsResponse.json();
|
|
console.log('Stats data:', stats);
|
|
starCountEl.textContent = stats.star_count || 0;
|
|
} else {
|
|
const errorText = await statsResponse.text();
|
|
console.error('Failed to load stats:', errorText);
|
|
}
|
|
} catch (err) {
|
|
console.error('Error loading star count:', err);
|
|
}
|
|
}
|
|
|
|
// 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/images/${repository}/manifests/${digest}`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
|
|
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/images/${repository}/manifests/${digest}?confirm=true`, {
|
|
method: 'DELETE',
|
|
credentials: 'include',
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
});
|
|
}
|
|
});
|