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}

`; 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); }); }); // Add add-to-list event listeners document.querySelectorAll('.add-to-list-btn').forEach(btn => { btn.addEventListener('click', (e) => { const linkId = e.target.closest('.link-card').dataset.id; handleAddToLists(linkId); }); }); // Update UI based on auth status after rendering updateUIBasedOnAuth(); } // 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'); const addToListBtn = newCard.querySelector('.add-to-list-btn'); if (deleteBtn) { deleteBtn.addEventListener('click', () => handleDeleteLink(link.id)); } if (archiveBtn) { archiveBtn.addEventListener('click', () => handleArchiveLink(link.id)); } if (addToListBtn) { addToListBtn.addEventListener('click', () => handleAddToLists(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' }, credentials: 'include', body: JSON.stringify({ archived: newArchivedStatus }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to archive link'); if (handled) return; } // 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', credentials: 'include' }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to delete link'); if (handled) return; } // 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); } // Load all lists async function loadLists() { try { const response = await fetch(LISTS_API_BASE); if (!response.ok) throw new Error('Failed to load lists'); allLists = await response.json(); // Ensure all lists have public property allLists = allLists.map(list => ({ ...list, public: list.public || false })); // Only update filter chips if the filter section is visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } return allLists; } catch (error) { showMessage('Failed to load lists', 'error'); console.error('Error loading lists:', error); return []; } } // Setup lists management function setupListsManagement() { // Toggle filter section visibility listsToggle.addEventListener('click', (e) => { const isVisible = listFilterWrapper.classList.contains('show'); if (isVisible) { listFilterWrapper.classList.remove('show'); listsToggle.classList.remove('active'); // Force remove active state on mobile - use double requestAnimationFrame for listsToggle requestAnimationFrame(() => { listsToggle.blur(); if (document.activeElement === listsToggle) { document.activeElement.blur(); } // Second frame to ensure it's fully cleared requestAnimationFrame(() => { listsToggle.blur(); // Force style recalculation listsToggle.style.pointerEvents = 'none'; setTimeout(() => { listsToggle.style.pointerEvents = ''; }, 0); }); }); // Reset filters when hiding the section selectedListFilters = []; updateUrlForListFilter(); updateListFilterChips(); displayLinks(getFilteredLinks()); } else { listFilterWrapper.classList.add('show'); listsToggle.classList.add('active'); // Update filter chips when showing the section updateListFilterChips(); } }); // Open lists modal from edit button editListsBtn.addEventListener('click', () => { listsModal.classList.add('show'); renderListsList(); }); // Close lists modal closeListsModal.addEventListener('click', () => { listsModal.classList.remove('show'); }); // Close on backdrop click listsModal.addEventListener('click', (e) => { if (e.target === listsModal) { listsModal.classList.remove('show'); } }); // Create list createListBtn.addEventListener('click', handleCreateList); newListName.addEventListener('keypress', (e) => { if (e.key === 'Enter') { handleCreateList(); } }); // List selection overlay closeListSelection.addEventListener('click', () => { listSelectionOverlay.classList.remove('show'); currentLinkForListSelection = null; }); // Close on backdrop click listSelectionOverlay.addEventListener('click', (e) => { if (e.target === listSelectionOverlay) { listSelectionOverlay.classList.remove('show'); currentLinkForListSelection = null; } }); } // Render lists in the modal function renderListsList() { if (allLists.length === 0) { listsList.innerHTML = '

No lists yet. Create your first list!

'; return; } listsList.innerHTML = allLists.map(list => { // Ensure public property exists and defaults to false (private) const isPublic = list.public === true; return `
`; }).join(''); // Add event listeners listsList.querySelectorAll('.toggle-public-btn').forEach(btn => { btn.addEventListener('click', (e) => { const listId = e.target.closest('.toggle-public-btn').dataset.id; handleTogglePublic(listId); }); }); listsList.querySelectorAll('.public-url-btn').forEach(btn => { btn.addEventListener('click', (e) => { const listId = e.target.closest('.public-url-btn').dataset.id; const listUrl = `${window.location.origin}${window.location.pathname}#list=${listId}`; window.open(listUrl, '_blank'); }); }); listsList.querySelectorAll('.delete-list-btn').forEach(btn => { btn.addEventListener('click', (e) => { const listId = e.target.closest('.delete-list-btn').dataset.id; handleDeleteList(listId); }); }); listsList.querySelectorAll('.list-name-input').forEach(input => { // Auto-save on blur (when input loses focus) input.addEventListener('blur', (e) => { const listId = e.target.dataset.id; handleUpdateList(listId); }); // Also save on Enter key input.addEventListener('keypress', (e) => { if (e.key === 'Enter') { e.target.blur(); // Trigger blur which will auto-save } }); }); } // Handle create list async function handleCreateList() { const name = newListName.value.trim(); if (!name) { showMessage('Please enter a list name', 'error'); return; } try { const response = await fetch(LISTS_API_BASE, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to create list'); if (handled) return; } const data = await response.json(); allLists.push(data); newListName.value = ''; renderListsList(); // Only update filter chips if the filter section is visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } showMessage('List created successfully!', 'success'); } catch (error) { showMessage(error.message, 'error'); console.error('Error creating list:', error); } } // Handle update list async function handleUpdateList(listId) { const input = listsList.querySelector(`.list-name-input[data-id="${listId}"]`); const name = input.value.trim(); if (!name) { showMessage('List name cannot be empty', 'error'); return; } try { const response = await fetch(`${LISTS_API_BASE}/${listId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to update list'); if (handled) return; } const data = await response.json(); const listIndex = allLists.findIndex(l => l.id === listId); if (listIndex !== -1) { allLists[listIndex] = data; } renderListsList(); // Only update filter chips if the filter section is visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } showMessage('List updated successfully!', 'success'); } catch (error) { showMessage(error.message, 'error'); console.error('Error updating list:', error); } } // Handle toggle public status async function handleTogglePublic(listId) { const list = allLists.find(l => l.id === listId); if (!list) return; // Current state: false/undefined = private, true = public // Toggle: if currently private (false), make it public (true) // if currently public (true), make it private (false) const currentIsPublic = list.public === true; const newPublicStatus = !currentIsPublic; try { const response = await fetch(`${LISTS_API_BASE}/${listId}/public`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ public: newPublicStatus }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to update list public status'); if (handled) return; } const data = await response.json(); // Update local array const listIndex = allLists.findIndex(l => l.id === listId); if (listIndex !== -1) { allLists[listIndex].public = newPublicStatus; } renderListsList(); showMessage(newPublicStatus ? 'List is now public' : 'List is now private', 'success'); } catch (error) { showMessage('Failed to update list public status', 'error'); console.error('Error updating list public status:', error); } } // Handle delete list async function handleDeleteList(listId) { if (!confirm('Are you sure you want to delete this list? Links assigned to this list will be unassigned.')) { return; } try { const response = await fetch(`${LISTS_API_BASE}/${listId}`, { method: 'DELETE', credentials: 'include' }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to delete list'); if (handled) return; } allLists = allLists.filter(list => list.id !== listId); selectedListFilters = selectedListFilters.filter(id => id !== listId); renderListsList(); // Only update filter chips if the filter section is visible if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } displayLinks(getFilteredLinks()); showMessage('List deleted successfully', 'success'); } catch (error) { showMessage('Failed to delete list', 'error'); console.error('Error deleting list:', error); } } // Handle add to lists function handleAddToLists(linkId) { currentLinkForListSelection = linkId; const link = allLinks.find(l => l.id === linkId); const linkListIds = link?.listIds || []; // Render list checkboxes if (allLists.length === 0) { listSelectionBody.innerHTML = '

No lists available. Create a list first!

'; } else { listSelectionBody.innerHTML = allLists.map(list => ` `).join(''); // Add event listeners to checkboxes for auto-save listSelectionBody.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.addEventListener('change', () => { handleCheckboxChange(linkId); }); }); } listSelectionOverlay.classList.add('show'); } // Handle checkbox change (auto-save) async function handleCheckboxChange(linkId) { if (!linkId) return; const checkboxes = listSelectionBody.querySelectorAll('input[type="checkbox"]:checked'); const selectedListIds = Array.from(checkboxes).map(cb => cb.value); try { const response = await fetch(`${API_BASE}/${linkId}/lists`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ listIds: selectedListIds }) }); if (!response.ok) { const handled = await handleApiError(response, 'Failed to update link lists'); if (handled) return; } const data = await response.json(); // Update local array const linkIndex = allLinks.findIndex(l => l.id === linkId); if (linkIndex !== -1) { allLinks[linkIndex].listIds = selectedListIds; } // No need to refresh the display - the link is already visible // Only update if the link's visibility might have changed due to list filters // But since we're just assigning lists, not changing visibility, we skip the refresh } catch (error) { showMessage('Failed to update link lists', 'error'); console.error('Error updating link lists:', error); // Revert checkbox state on error const link = allLinks.find(l => l.id === linkId); const linkListIds = link?.listIds || []; listSelectionBody.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { checkbox.checked = linkListIds.includes(checkbox.value); }); } } // Reset all filters and load all links function resetAllFilters() { // Clear search searchInput.value = ''; // Reset archive toggle showArchived = false; archiveToggle.checked = false; // Clear list filters selectedListFilters = []; updateUrlForListFilter(); if (listFilterWrapper.classList.contains('show')) { updateListFilterChips(); } // Hide search and add link wrappers if visible searchWrapper.classList.remove('show'); searchToggle.classList.remove('active'); addLinkWrapper.classList.remove('show'); addLinkToggle.classList.remove('active'); // Load all links loadLinks(); } // Setup logo click to reset filters function setupLogoClick() { const appLogo = document.getElementById('appLogo'); if (appLogo) { appLogo.style.cursor = 'pointer'; appLogo.addEventListener('click', resetAllFilters); } } // Setup list filtering function setupListFiltering() { clearListFilters.addEventListener('click', () => { selectedListFilters = []; updateUrlForListFilter(); updateListFilterChips(); displayLinks(getFilteredLinks()); }); } // Setup URL navigation for lists function setupUrlNavigation() { // Listen for hash changes (browser back/forward) window.addEventListener('hashchange', () => { checkUrlForListFilter(); }); } // Check URL for list filter parameter function checkUrlForListFilter() { const hash = window.location.hash; if (!hash) { return; } // Parse hash like #list=LIST_ID or #list=ID1,ID2,ID3 const listMatch = hash.match(/^#list=(.+)$/); if (listMatch) { const listIds = listMatch[1].split(',').filter(id => id.trim()); // Validate that all list IDs exist const validListIds = listIds.filter(id => allLists.some(list => list.id === id)); if (validListIds.length > 0) { selectedListFilters = validListIds; // Don't show filter section when loading from URL - just apply the filter silently // The user can manually toggle it if they want to see/modify filters displayLinks(getFilteredLinks()); } } } // Update URL based on current list filters function updateUrlForListFilter() { if (selectedListFilters.length > 0) { const listIds = selectedListFilters.join(','); window.location.hash = `list=${listIds}`; } else { // Remove hash if no filters if (window.location.hash.startsWith('#list=')) { window.history.replaceState(null, '', window.location.pathname + window.location.search); } } } // Update list filter chips function updateListFilterChips() { // Filter lists based on authentication status let listsToShow = allLists; if (!isAuthenticated) { // Only show public lists when not logged in listsToShow = allLists.filter(list => list.public === true); } if (listsToShow.length === 0) { // Don't hide the wrapper if it's already shown, just show empty state listFilterChips.innerHTML = '

No lists available. Click Edit to create lists.

'; return; } // Only show the wrapper if it has the 'show' class (user has toggled it) if (!listFilterWrapper.classList.contains('show')) { return; } listFilterChips.innerHTML = listsToShow.map(list => { const isSelected = selectedListFilters.includes(list.id); return ` `; }).join(''); // Add event listeners listFilterChips.querySelectorAll('.list-filter-chip').forEach(chip => { chip.addEventListener('click', () => { const listId = chip.dataset.id; const index = selectedListFilters.indexOf(listId); if (index > -1) { selectedListFilters.splice(index, 1); } else { selectedListFilters.push(listId); } updateUrlForListFilter(); updateListFilterChips(); displayLinks(getFilteredLinks()); }); }); } // Escape HTML to prevent XSS function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }