const API_BASE = '/api/links'; const LISTS_API_BASE = '/api/lists'; const AUTH_API_BASE = '/api/auth'; // 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'); const listsToggle = document.getElementById('listsToggle'); const listsModal = document.getElementById('listsModal'); const closeListsModal = document.getElementById('closeListsModal'); const listsList = document.getElementById('listsList'); const newListName = document.getElementById('newListName'); const createListBtn = document.getElementById('createListBtn'); const listSelectionOverlay = document.getElementById('listSelectionOverlay'); const listSelectionBody = document.getElementById('listSelectionBody'); const closeListSelection = document.getElementById('closeListSelection'); const listFilterWrapper = document.getElementById('listFilterWrapper'); const listFilterChips = document.getElementById('listFilterChips'); const clearListFilters = document.getElementById('clearListFilters'); const editListsBtn = document.getElementById('editListsBtn'); const loginToggle = document.getElementById('loginToggle'); const loginModal = document.getElementById('loginModal'); const closeLoginModal = document.getElementById('closeLoginModal'); const loginForm = document.getElementById('loginForm'); const loginUsername = document.getElementById('loginUsername'); const loginPassword = document.getElementById('loginPassword'); const loginSubmitBtn = document.getElementById('loginSubmitBtn'); const loginError = document.getElementById('loginError'); const userInfo = document.getElementById('userInfo'); const usernameDisplay = document.getElementById('usernameDisplay'); const logoutBtn = document.getElementById('logoutBtn'); // State let allLinks = []; let allLists = []; let searchTimeout = null; let showArchived = false; let currentLayout = localStorage.getItem('linkdingLayout') || 'masonry'; // Load from localStorage or default let selectedListFilters = []; let currentLinkForListSelection = null; let isAuthenticated = false; let currentUser = null; // Initialize app document.addEventListener('DOMContentLoaded', () => { checkAuthStatus().then(() => { loadLinks(); loadLists().then(() => { // After lists are loaded, check URL for list filter checkUrlForListFilter(); }); }); setupEventListeners(); setupAuthentication(); setupScrollToTop(); setupMobileSearch(); setupMobileAddLink(); setupLayoutToggle(); setupListsManagement(); setupListFiltering(); setupUrlNavigation(); setupLogoClick(); setupMobileButtonBlur(); applyLayout(currentLayout); registerServiceWorker(); }); // Setup button blur on mobile after click function setupMobileButtonBlur() { // Check if device is mobile/touch device const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || ('ontouchstart' in window) || (navigator.maxTouchPoints > 0); if (isMobile) { // Add blur handler to all buttons after click document.addEventListener('click', (e) => { if (e.target.tagName === 'BUTTON' || e.target.closest('button')) { const button = e.target.tagName === 'BUTTON' ? e.target : e.target.closest('button'); // Use setTimeout to ensure blur happens after any click handlers setTimeout(() => { if (button && document.activeElement === button) { button.blur(); } }, 100); } }, true); // Use capture phase to catch all button clicks } } // 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); }); }); } } // Authentication functions async function checkAuthStatus() { try { const response = await fetch(AUTH_API_BASE + '/status', { credentials: 'include' }); if (!response.ok) throw new Error('Failed to check auth status'); const data = await response.json(); const wasAuthenticated = isAuthenticated; isAuthenticated = data.authenticated; currentUser = data.user; updateUIBasedOnAuth(); // If authentication status changed, reload lists to get the correct set if (wasAuthenticated !== isAuthenticated) { await loadLists(); } } catch (error) { console.error('Error checking auth status:', error); isAuthenticated = false; currentUser = null; updateUIBasedOnAuth(); } } function updateUIBasedOnAuth() { // Show/hide login button and user info if (isAuthenticated) { loginToggle.style.display = 'none'; userInfo.style.display = 'flex'; usernameDisplay.textContent = currentUser?.username || 'User'; } else { loginToggle.style.display = 'flex'; userInfo.style.display = 'none'; } // Show/hide modify buttons (lists toggle is visible to all for filtering) const modifyButtons = document.querySelectorAll('.add-link-toggle-btn, .delete-btn, .archive-btn, .add-to-list-btn, #createListBtn, .edit-lists-btn'); modifyButtons.forEach(btn => { if (btn) { btn.style.display = isAuthenticated ? '' : 'none'; } }); // Lists toggle button is visible to all users for filtering // But the edit button inside the list filter section should be hidden when not authenticated const editListsBtn = document.getElementById('editListsBtn'); if (editListsBtn) { editListsBtn.style.display = isAuthenticated ? '' : 'none'; } // Hide add link wrapper if not authenticated if (!isAuthenticated && addLinkWrapper) { addLinkWrapper.classList.remove('show'); if (addLinkToggle) addLinkToggle.classList.remove('active'); } } function setupAuthentication() { // Login toggle loginToggle.addEventListener('click', () => { loginModal.classList.add('show'); loginUsername.focus(); }); // Close login modal closeLoginModal.addEventListener('click', () => { loginModal.classList.remove('show'); loginForm.reset(); loginError.style.display = 'none'; }); // Close on backdrop click loginModal.addEventListener('click', (e) => { if (e.target === loginModal) { loginModal.classList.remove('show'); loginForm.reset(); loginError.style.display = 'none'; } }); // Login form submission loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = loginUsername.value.trim(); const password = loginPassword.value; if (!username || !password) { showLoginError('Please enter both username and password'); return; } // Disable form loginSubmitBtn.disabled = true; const btnText = loginSubmitBtn.querySelector('.btn-text'); const btnLoader = loginSubmitBtn.querySelector('.btn-loader'); if (btnText) btnText.style.display = 'none'; if (btnLoader) btnLoader.style.display = 'flex'; loginError.style.display = 'none'; try { const response = await fetch(AUTH_API_BASE + '/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ username, password }) }); const data = await response.json(); if (!response.ok) { throw new Error(data.error || 'Login failed'); } isAuthenticated = true; currentUser = data.user; updateUIBasedOnAuth(); loginModal.classList.remove('show'); loginForm.reset(); showMessage('Login successful!', 'success'); // Reload lists to get all lists (not just public ones) loadLists().then(() => { // Update filter chips if visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } }); // Refresh links to show all links (not just public ones) loadLinks(); } catch (error) { showLoginError(error.message || 'Login failed. Please check your credentials.'); console.error('Login error:', error); } finally { loginSubmitBtn.disabled = false; if (btnText) btnText.style.display = 'inline'; if (btnLoader) btnLoader.style.display = 'none'; } }); // Logout logoutBtn.addEventListener('click', async () => { try { const response = await fetch(AUTH_API_BASE + '/logout', { method: 'POST', credentials: 'include' }); if (!response.ok) throw new Error('Logout failed'); isAuthenticated = false; currentUser = null; updateUIBasedOnAuth(); showMessage('Logged out successfully', 'success'); // Reload lists to get only public lists loadLists().then(() => { // Clear any selected filters that are no longer valid (private lists) selectedListFilters = selectedListFilters.filter(listId => { return allLists.some(list => list.id === listId && list.public === true); }); // Update filter chips if visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } }); // Reset all filters and refresh links to show only public links resetAllFilters(); } catch (error) { showMessage('Logout failed', 'error'); console.error('Logout error:', error); } }); } function showLoginError(message) { loginError.textContent = message; loginError.style.display = 'block'; } // Helper function to handle API errors, especially authentication errors async function handleApiError(response, defaultMessage) { if (response.status === 401) { // Authentication required isAuthenticated = false; currentUser = null; updateUIBasedOnAuth(); showMessage('Please login to perform this action', 'error'); loginModal.classList.add('show'); return true; // Indicates auth error was handled } const data = await response.json().catch(() => ({})); throw new Error(data.error || defaultMessage); } // Event listeners function setupEventListeners() { linkForm.addEventListener('submit', handleAddLink); searchInput.addEventListener('input', handleSearch); archiveToggle.addEventListener('change', 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'); // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing requestAnimationFrame(() => { searchToggle.blur(); if (document.activeElement === searchToggle) { document.activeElement.blur(); } }); // Hide add link if visible if (addLinkWrapper && addLinkWrapper.classList.contains('show')) { addLinkWrapper.classList.remove('show'); if (addLinkToggle) { addLinkToggle.classList.remove('active'); addLinkToggle.blur(); } } // 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'); addLinkToggle.blur(); } } // 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'); // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing requestAnimationFrame(() => { addLinkToggle.blur(); if (document.activeElement === addLinkToggle) { document.activeElement.blur(); } }); // 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'); searchToggle.blur(); } 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(); const wasVisible = layoutDropdown.classList.contains('show'); layoutDropdown.classList.toggle('show'); layoutToggle.classList.toggle('active'); // Blur button when closing dropdown to remove focus/active state if (wasVisible) { requestAnimationFrame(() => { layoutToggle.blur(); if (document.activeElement === layoutToggle) { document.activeElement.blur(); } }); } }); // 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'); // Force remove active state on mobile - use requestAnimationFrame to ensure it happens after click processing requestAnimationFrame(() => { layoutToggle.blur(); if (document.activeElement === layoutToggle) { document.activeElement.blur(); } }); } }); // 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 and listIds properties allLinks = allLinks.map(link => ({ ...link, archived: link.archived || false, listIds: link.listIds || [] })); displayLinks(getFilteredLinks()); } catch (error) { showMessage('Failed to load links', 'error'); console.error('Error loading links:', error); } } // Get filtered links based on archived status and list filters function getFilteredLinks() { return allLinks.filter(link => { const isArchived = link.archived === true; const matchesArchiveFilter = showArchived ? isArchived : !isArchived; // Filter by selected lists if (selectedListFilters.length > 0) { const linkListIds = link.listIds || []; const matchesListFilter = selectedListFilters.some(listId => linkListIds.includes(listId)); return matchesArchiveFilter && matchesListFilter; } return matchesArchiveFilter; }); } // 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' }, credentials: 'include', body: JSON.stringify({ url }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to add link'); if (handled) return; } const data = await response.json(); // Ensure archived and listIds properties exist data.archived = data.archived || false; data.listIds = data.listIds || []; // If there are active list filters, automatically assign the link to those lists if (selectedListFilters.length > 0) { try { const listResponse = await fetch(`${API_BASE}/${data.id}/lists`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ listIds: selectedListFilters }) }); if (listResponse.ok) { const updatedLink = await listResponse.json(); data.listIds = updatedLink.listIds || selectedListFilters; } } catch (error) { // If assigning to lists fails, continue anyway - link is still added console.error('Error assigning link to lists:', error); } } // 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 and list filters const filtered = filteredLinks.filter(link => { const isArchived = link.archived === true; const matchesArchiveFilter = showArchived ? isArchived : !isArchived; // Filter by selected lists if (selectedListFilters.length > 0) { const linkListIds = link.listIds || []; const matchesListFilter = selectedListFilters.some(listId => linkListIds.includes(listId)); return matchesArchiveFilter && matchesListFilter; } return matchesArchiveFilter; }); displayLinks(filtered); } catch (error) { showMessage('Search failed', 'error'); console.error('Error searching:', error); } }, 300); } // Handle archive toggle function handleToggleArchive() { showArchived = archiveToggle.checked; // 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}
${escapeHtml(link.description)}
` : ''} ${escapeHtml(link.url)}No links found. ${message}