// 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 = ' 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 = ''; } else { details.style.display = 'none'; btn.innerHTML = ''; } // 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(); } } // Upload repository avatar async function uploadAvatar(input, repository) { const file = input.files[0]; if (!file) return; // Client-side validation const validTypes = ['image/png', 'image/jpeg', 'image/webp']; if (!validTypes.includes(file.type)) { alert('Please select a PNG, JPEG, or WebP image'); return; } if (file.size > 3 * 1024 * 1024) { alert('Image must be less than 3MB'); return; } const formData = new FormData(); formData.append('avatar', file); try { const response = await fetch(`/api/images/${repository}/avatar`, { method: 'POST', credentials: 'include', body: formData }); if (response.status === 401) { window.location.href = '/auth/oauth/login'; return; } if (!response.ok) { const error = await response.text(); throw new Error(error); } const data = await response.json(); // Update the avatar image on the page const wrapper = document.querySelector('.repo-hero-icon-wrapper'); if (!wrapper) return; const existingImg = wrapper.querySelector('.repo-hero-icon'); const placeholder = wrapper.querySelector('.repo-hero-icon-placeholder'); if (existingImg) { existingImg.src = data.avatarURL; } else if (placeholder) { const newImg = document.createElement('img'); newImg.src = data.avatarURL; newImg.alt = repository; newImg.className = 'repo-hero-icon'; placeholder.replaceWith(newImg); } } catch (err) { console.error('Error uploading avatar:', err); alert('Failed to upload avatar: ' + err.message); } // Clear input so same file can be selected again input.value = ''; } // 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(); } }); } }); // Login page typeahead functionality class LoginTypeahead { constructor(inputElement) { this.input = inputElement; this.dropdown = null; this.debounceTimer = null; this.currentFocus = -1; this.results = []; this.isLoading = false; this.init(); } init() { // Create dropdown element this.createDropdown(); // Event listeners this.input.addEventListener('input', (e) => this.handleInput(e)); this.input.addEventListener('keydown', (e) => this.handleKeydown(e)); this.input.addEventListener('focus', () => this.handleFocus()); // 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 = 'typeahead-dropdown'; this.dropdown.style.display = 'none'; this.input.parentNode.insertBefore(this.dropdown, this.input.nextSibling); } handleInput(e) { const value = e.target.value.trim(); // Clear debounce timer clearTimeout(this.debounceTimer); if (value.length < 2) { this.showRecentAccounts(); return; } // Debounce API call (200ms) this.debounceTimer = setTimeout(() => { this.searchActors(value); }, 200); } handleFocus() { const value = this.input.value.trim(); if (value.length < 2) { this.showRecentAccounts(); } } async searchActors(query) { this.isLoading = true; this.showLoading(); try { const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.searchActorsTypeahead?q=${encodeURIComponent(query)}&limit=3`; const response = await fetch(url); if (!response.ok) { throw new Error('Failed to fetch suggestions'); } const data = await response.json(); this.results = data.actors || []; this.renderResults(); } catch (err) { console.error('Typeahead error:', err); this.hideDropdown(); } finally { this.isLoading = false; } } showLoading() { this.dropdown.innerHTML = '