const API_BASE = '/api/links'; // DOM elements const linkForm = document.getElementById('linkForm'); const linkInput = document.getElementById('linkInput'); const addButton = document.getElementById('addButton'); const searchInput = document.getElementById('searchInput'); const linksContainer = document.getElementById('linksContainer'); const toastContainer = document.getElementById('toastContainer'); const archiveToggle = document.getElementById('archiveToggle'); const scrollToTopBtn = document.getElementById('scrollToTopBtn'); const searchToggle = document.getElementById('searchToggle'); const searchWrapper = document.getElementById('searchWrapper'); const addLinkToggle = document.getElementById('addLinkToggle'); const addLinkWrapper = document.getElementById('addLinkWrapper'); const layoutToggle = document.getElementById('layoutToggle'); const layoutDropdown = document.getElementById('layoutDropdown'); // State let allLinks = []; let searchTimeout = null; let showArchived = false; let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default // Initialize app document.addEventListener('DOMContentLoaded', () => { loadLinks(); setupEventListeners(); setupScrollToTop(); setupMobileSearch(); setupMobileAddLink(); setupLayoutToggle(); applyLayout(currentLayout); registerServiceWorker(); }); // Register service worker for PWA function registerServiceWorker() { if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') .then((registration) => { console.log('Service Worker registered successfully:', registration.scope); }) .catch((error) => { console.log('Service Worker registration failed:', error); }); }); } } // Event listeners function setupEventListeners() { linkForm.addEventListener('submit', handleAddLink); searchInput.addEventListener('input', handleSearch); archiveToggle.addEventListener('click', handleToggleArchive); } // Setup search toggle (works on both desktop and mobile) function setupMobileSearch() { if (!searchToggle || !searchWrapper) return; searchToggle.addEventListener('click', () => { const isVisible = searchWrapper.classList.contains('show'); if (isVisible) { // Hide search searchWrapper.classList.remove('show'); searchToggle.classList.remove('active'); // Hide add link if visible if (addLinkWrapper && addLinkWrapper.classList.contains('show')) { addLinkWrapper.classList.remove('show'); if (addLinkToggle) addLinkToggle.classList.remove('active'); } // Clear search and reset if needed if (searchInput.value.trim()) { searchInput.value = ''; loadLinks(); } } else { // Hide add link if visible if (addLinkWrapper && addLinkWrapper.classList.contains('show')) { addLinkWrapper.classList.remove('show'); if (addLinkToggle) addLinkToggle.classList.remove('active'); } // Show search searchWrapper.classList.add('show'); searchToggle.classList.add('active'); // Focus the input after a brief delay for animation setTimeout(() => { searchInput.focus(); }, 100); } }); } // Setup add link toggle (works on both desktop and mobile) function setupMobileAddLink() { if (!addLinkToggle || !addLinkWrapper) return; addLinkToggle.addEventListener('click', () => { const isVisible = addLinkWrapper.classList.contains('show'); if (isVisible) { // Hide add link addLinkWrapper.classList.remove('show'); addLinkToggle.classList.remove('active'); // Clear input if needed if (linkInput.value.trim()) { linkInput.value = ''; } } else { // Hide search if visible if (searchWrapper && searchWrapper.classList.contains('show')) { searchWrapper.classList.remove('show'); if (searchToggle) searchToggle.classList.remove('active'); if (searchInput.value.trim()) { searchInput.value = ''; loadLinks(); } } // Show add link addLinkWrapper.classList.add('show'); addLinkToggle.classList.add('active'); // Focus the input after a brief delay for animation setTimeout(() => { linkInput.focus(); }, 100); } }); } // Setup layout toggle function setupLayoutToggle() { if (!layoutToggle || !layoutDropdown) return; // Toggle dropdown on button click layoutToggle.addEventListener('click', (e) => { e.stopPropagation(); layoutDropdown.classList.toggle('show'); layoutToggle.classList.toggle('active'); }); // Close dropdown when clicking outside document.addEventListener('click', (e) => { if (!layoutToggle.contains(e.target) && !layoutDropdown.contains(e.target)) { layoutDropdown.classList.remove('show'); layoutToggle.classList.remove('active'); } }); // Handle layout option clicks layoutDropdown.querySelectorAll('.layout-option').forEach(option => { option.addEventListener('click', () => { const layout = option.dataset.layout; applyLayout(layout); layoutDropdown.classList.remove('show'); layoutToggle.classList.remove('active'); }); }); } // Apply layout function applyLayout(layout) { currentLayout = layout; linksContainer.className = 'links-container'; linksContainer.classList.add(`layout-${layout}`); // Save to localStorage localStorage.setItem('linkdingLayout', layout); // Update active state in dropdown layoutDropdown.querySelectorAll('.layout-option').forEach(option => { option.classList.toggle('active', option.dataset.layout === layout); }); } // Setup scroll to top button function setupScrollToTop() { // Show/hide button based on scroll position window.addEventListener('scroll', () => { if (window.pageYOffset > 300) { scrollToTopBtn.classList.add('show'); } else { scrollToTopBtn.classList.remove('show'); } }); // Scroll to top when button is clicked scrollToTopBtn.addEventListener('click', () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }); } // Load all links async function loadLinks() { try { const response = await fetch(API_BASE); if (!response.ok) throw new Error('Failed to load links'); allLinks = await response.json(); // Ensure all links have archived property allLinks = allLinks.map(link => ({ ...link, archived: link.archived || false })); displayLinks(getFilteredLinks()); } catch (error) { showMessage('Failed to load links', 'error'); console.error('Error loading links:', error); } } // Get filtered links based on archived status function getFilteredLinks() { return allLinks.filter(link => { const isArchived = link.archived === true; return showArchived ? isArchived : !isArchived; }); } // Handle add link form submission async function handleAddLink(e) { e.preventDefault(); const url = linkInput.value.trim(); if (!url) return; // Disable form addButton.disabled = true; const btnText = addButton.querySelector('.btn-text'); const btnLoader = addButton.querySelector('.btn-loader'); if (btnText) btnText.style.display = 'none'; if (btnLoader) btnLoader.style.display = 'flex'; try { const response = await fetch(API_BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Failed to add link'); } // Ensure archived property exists data.archived = data.archived || false; // Add to beginning of array allLinks.unshift(data); displayLinks(getFilteredLinks()); // Clear input linkInput.value = ''; showMessage('Link added successfully!', 'success'); } catch (error) { showMessage(error.message, 'error'); console.error('Error adding link:', error); } finally { // Re-enable form addButton.disabled = false; if (btnText) btnText.style.display = 'inline'; if (btnLoader) btnLoader.style.display = 'none'; } } // Handle search input function handleSearch(e) { const query = e.target.value.trim(); // Debounce search clearTimeout(searchTimeout); searchTimeout = setTimeout(async () => { if (!query) { displayLinks(getFilteredLinks()); return; } try { const response = await fetch(`${API_BASE}/search?q=${encodeURIComponent(query)}`); if (!response.ok) throw new Error('Search failed'); const filteredLinks = await response.json(); // Filter by archived status const archivedFiltered = filteredLinks.filter(link => { const isArchived = link.archived === true; return showArchived ? isArchived : !isArchived; }); displayLinks(archivedFiltered); } catch (error) { showMessage('Search failed', 'error'); console.error('Error searching:', error); } }, 300); } // Handle archive toggle function handleToggleArchive() { showArchived = !showArchived; archiveToggle.classList.toggle('active', showArchived); // Re-filter and display links const query = searchInput.value.trim(); if (query) { // Re-run search with new filter handleSearch({ target: searchInput }); } else { displayLinks(getFilteredLinks()); } } // Display links function displayLinks(links) { if (links.length === 0) { const filteredCount = getFilteredLinks().length; const totalCount = allLinks.length; const message = showArchived ? (totalCount === 0 ? 'Add your first link to get started!' : 'No archived links found.') : (totalCount === 0 ? 'Add your first link to get started!' : (filteredCount === 0 ? 'No active links found. Try a different search term or toggle to view archived links.' : 'Try a different search term.')); linksContainer.innerHTML = `

No links found. ${message}

`; return; } linksContainer.innerHTML = links.map(link => createLinkCard(link)).join(''); applyLayout(currentLayout); // Add delete event listeners document.querySelectorAll('.delete-btn').forEach(btn => { btn.addEventListener('click', (e) => { const linkId = e.target.closest('.link-card').dataset.id; handleDeleteLink(linkId); }); }); // Add archive event listeners document.querySelectorAll('.archive-btn').forEach(btn => { btn.addEventListener('click', (e) => { const linkId = e.target.closest('.link-card').dataset.id; handleArchiveLink(linkId); }); }); } // Create link card HTML function createLinkCard(link) { const date = new Date(link.createdAt); const formattedDate = date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); const imageHtml = link.image ? `${escapeHtml(link.title)}` : 'No image available'; return ` `; } // Update a single link card in place function updateLinkCard(link) { const linkCard = document.querySelector(`.link-card[data-id="${link.id}"]`); if (!linkCard) return; // Replace the card content linkCard.outerHTML = createLinkCard(link); // Re-attach event listeners to the new card const newCard = document.querySelector(`.link-card[data-id="${link.id}"]`); if (newCard) { const deleteBtn = newCard.querySelector('.delete-btn'); const archiveBtn = newCard.querySelector('.archive-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => handleDeleteLink(link.id)); } if (archiveBtn) { archiveBtn.addEventListener('click', () => handleArchiveLink(link.id)); } } } // Remove a single link card function removeLinkCard(id) { const linkCard = document.querySelector(`.link-card[data-id="${id}"]`); if (linkCard) { linkCard.remove(); // Check if we need to show empty state const remainingCards = document.querySelectorAll('.link-card'); if (remainingCards.length === 0) { const filteredCount = getFilteredLinks().length; const totalCount = allLinks.length; const message = showArchived ? (totalCount === 0 ? 'Add your first link to get started!' : 'No archived links found.') : (totalCount === 0 ? 'Add your first link to get started!' : (filteredCount === 0 ? 'No active links found. Try a different search term or toggle to view archived links.' : 'Try a different search term.')); linksContainer.innerHTML = `

No links found. ${message}

`; } } } // Check if a link should be visible based on current filters function shouldLinkBeVisible(link) { const isArchived = link.archived === true; const matchesArchiveFilter = showArchived ? isArchived : !isArchived; const query = searchInput.value.trim(); if (query) { const queryLower = query.toLowerCase(); const titleMatch = link.title?.toLowerCase().includes(queryLower); const descMatch = link.description?.toLowerCase().includes(queryLower); const urlMatch = link.url?.toLowerCase().includes(queryLower); const matchesSearch = titleMatch || descMatch || urlMatch; return matchesArchiveFilter && matchesSearch; } return matchesArchiveFilter; } // Handle archive link async function handleArchiveLink(id) { try { const link = allLinks.find(l => l.id === id); if (!link) return; const newArchivedStatus = !link.archived; const response = await fetch(`${API_BASE}/${id}/archive`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ archived: newArchivedStatus }) }); if (!response.ok) throw new Error('Failed to archive link'); // Update local array link.archived = newArchivedStatus; // Update or remove the link card without refreshing the whole list if (shouldLinkBeVisible(link)) { // Link should still be visible, update it in place updateLinkCard(link); } else { // Link should no longer be visible, remove it removeLinkCard(id); } showMessage(newArchivedStatus ? 'Link archived successfully' : 'Link unarchived successfully', 'success'); } catch (error) { showMessage('Failed to archive link', 'error'); console.error('Error archiving link:', error); } } // Handle delete link async function handleDeleteLink(id) { if (!confirm('Are you sure you want to delete this link?')) { return; } try { const response = await fetch(`${API_BASE}/${id}`, { method: 'DELETE' }); if (!response.ok) throw new Error('Failed to delete link'); // Remove from local array allLinks = allLinks.filter(link => link.id !== id); displayLinks(getFilteredLinks()); showMessage('Link deleted successfully', 'success'); } catch (error) { showMessage('Failed to delete link', 'error'); console.error('Error deleting link:', error); } } // Show toast message function showMessage(text, type = 'success') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = text; // Add to container toastContainer.appendChild(toast); // Trigger animation requestAnimationFrame(() => { toast.classList.add('show'); }); // Remove after delay setTimeout(() => { toast.classList.remove('show'); setTimeout(() => { if (toast.parentNode) { toast.parentNode.removeChild(toast); } }, 300); // Wait for fade-out animation }, 3000); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }