Files

791 lines
25 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();
}
}
// 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 = '<div class="typeahead-loading">Searching...</div>';
this.dropdown.style.display = 'block';
}
renderResults() {
if (this.results.length === 0) {
this.hideDropdown();
return;
}
this.dropdown.innerHTML = '';
this.currentFocus = -1;
this.results.slice(0, 3).forEach((actor, index) => {
const item = this.createResultItem(actor, index);
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
createResultItem(actor, index) {
const item = document.createElement('div');
item.className = 'typeahead-item';
item.dataset.index = index;
item.dataset.handle = actor.handle;
// Avatar
const avatar = document.createElement('img');
avatar.className = 'typeahead-avatar';
avatar.src = actor.avatar || '/static/images/default-avatar.png';
avatar.alt = actor.handle;
avatar.onerror = () => {
avatar.src = '/static/images/default-avatar.png';
};
// Text container
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
// Display name
const displayName = document.createElement('div');
displayName.className = 'typeahead-displayname';
displayName.textContent = actor.displayName || actor.handle;
// Handle
const handle = document.createElement('div');
handle.className = 'typeahead-handle';
handle.textContent = `@${actor.handle}`;
textContainer.appendChild(displayName);
textContainer.appendChild(handle);
item.appendChild(avatar);
item.appendChild(textContainer);
// Click handler
item.addEventListener('click', () => this.selectItem(actor.handle));
return item;
}
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 = 'typeahead-header';
header.textContent = 'Recent accounts';
this.dropdown.appendChild(header);
recent.forEach((handle, index) => {
const item = document.createElement('div');
item.className = 'typeahead-item typeahead-recent';
item.dataset.index = index;
item.dataset.handle = handle;
const textContainer = document.createElement('div');
textContainer.className = 'typeahead-text';
const handleDiv = document.createElement('div');
handleDiv.className = 'typeahead-handle';
handleDiv.textContent = handle;
textContainer.appendChild(handleDiv);
item.appendChild(textContainer);
item.addEventListener('click', () => this.selectItem(handle));
this.dropdown.appendChild(item);
});
this.dropdown.style.display = 'block';
}
selectItem(handle) {
this.input.value = handle;
this.hideDropdown();
this.saveRecentAccount(handle);
// Optionally submit the form automatically
// this.input.form.submit();
}
hideDropdown() {
this.dropdown.style.display = 'none';
this.currentFocus = -1;
}
handleKeydown(e) {
// If dropdown is hidden, only respond to ArrowDown to show it
if (this.dropdown.style.display === 'none') {
if (e.key === 'ArrowDown') {
e.preventDefault();
const value = this.input.value.trim();
if (value.length >= 2) {
this.searchActors(value);
} else {
this.showRecentAccounts();
}
}
return;
}
const items = this.dropdown.querySelectorAll('.typeahead-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') {
if (this.currentFocus > -1 && items[this.currentFocus]) {
e.preventDefault();
const handle = items[this.currentFocus].dataset.handle;
this.selectItem(handle);
}
} else if (e.key === 'Escape') {
this.hideDropdown();
}
}
updateFocus(items) {
items.forEach((item, index) => {
if (index === this.currentFocus) {
item.classList.add('typeahead-focused');
} else {
item.classList.remove('typeahead-focused');
}
});
}
getRecentAccounts() {
try {
const recent = localStorage.getItem('atcr_recent_handles');
return recent ? JSON.parse(recent) : [];
} catch {
return [];
}
}
saveRecentAccount(handle) {
try {
let recent = this.getRecentAccounts();
// Remove if already exists
recent = recent.filter(h => h !== handle);
// Add to front
recent.unshift(handle);
// Keep only last 5
recent = recent.slice(0, 5);
localStorage.setItem('atcr_recent_handles', JSON.stringify(recent));
} catch (err) {
console.error('Failed to save recent account:', err);
}
}
}
// Initialize typeahead on login page
document.addEventListener('DOMContentLoaded', () => {
const handleInput = document.getElementById('handle');
if (handleInput && handleInput.closest('.login-form')) {
new LoginTypeahead(handleInput);
}
});