From 3378a13fe4b602a2ebd21725e8b4228c32a3d004 Mon Sep 17 00:00:00 2001 From: 0x1d Date: Sat, 15 Nov 2025 19:11:07 +0100 Subject: [PATCH] feat: lists --- public/app.js | 587 ++++++++++++++++++++++++++++++++++++++++- public/index.html | 79 ++++++ public/styles.css | 650 ++++++++++++++++++++++++++++++++++++++++++++++ public/sw.js | 84 ++++-- server.js | 177 +++++++++++++ 5 files changed, 1550 insertions(+), 27 deletions(-) diff --git a/public/app.js b/public/app.js index c18a97c..4e2e752 100644 --- a/public/app.js +++ b/public/app.js @@ -1,4 +1,5 @@ const API_BASE = '/api/links'; +const LISTS_API_BASE = '/api/lists'; // DOM elements const linkForm = document.getElementById('linkForm'); @@ -15,21 +16,46 @@ 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 saveListSelection = document.getElementById('saveListSelection'); +const cancelListSelection = document.getElementById('cancelListSelection'); +const listFilterWrapper = document.getElementById('listFilterWrapper'); +const listFilterChips = document.getElementById('listFilterChips'); +const clearListFilters = document.getElementById('clearListFilters'); +const editListsBtn = document.getElementById('editListsBtn'); // 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; // Initialize app document.addEventListener('DOMContentLoaded', () => { loadLinks(); + loadLists().then(() => { + // After lists are loaded, check URL for list filter + checkUrlForListFilter(); + }); setupEventListeners(); setupScrollToTop(); setupMobileSearch(); setupMobileAddLink(); setupLayoutToggle(); + setupListsManagement(); + setupListFiltering(); + setupUrlNavigation(); applyLayout(currentLayout); registerServiceWorker(); }); @@ -202,10 +228,11 @@ async function loadLinks() { if (!response.ok) throw new Error('Failed to load links'); allLinks = await response.json(); - // Ensure all links have archived property + // Ensure all links have archived and listIds properties allLinks = allLinks.map(link => ({ ...link, - archived: link.archived || false + archived: link.archived || false, + listIds: link.listIds || [] })); displayLinks(getFilteredLinks()); } catch (error) { @@ -214,11 +241,20 @@ async function loadLinks() { } } -// Get filtered links based on archived status +// Get filtered links based on archived status and list filters function getFilteredLinks() { return allLinks.filter(link => { const isArchived = link.archived === true; - return showArchived ? isArchived : !isArchived; + 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; }); } @@ -251,8 +287,31 @@ async function handleAddLink(e) { throw new Error(data.error || 'Failed to add link'); } - // Ensure archived property exists + // 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' + }, + 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()); @@ -288,12 +347,21 @@ function handleSearch(e) { if (!response.ok) throw new Error('Search failed'); const filteredLinks = await response.json(); - // Filter by archived status - const archivedFiltered = filteredLinks.filter(link => { + // Filter by archived status and list filters + const filtered = filteredLinks.filter(link => { const isArchived = link.archived === true; - return showArchived ? isArchived : !isArchived; + 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(archivedFiltered); + displayLinks(filtered); } catch (error) { showMessage('Search failed', 'error'); console.error('Error searching:', error); @@ -350,6 +418,14 @@ function displayLinks(links) { 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); + }); + }); } // Create link card HTML @@ -381,6 +457,16 @@ function createLinkCard(link) { + `; + }).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' + }, + body: JSON.stringify({ name }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to create list'); + } + + 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' + }, + body: JSON.stringify({ name }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update list'); + } + + 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' + }, + body: JSON.stringify({ public: newPublicStatus }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || 'Failed to update list public status'); + } + + // 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' + }); + + if (!response.ok) throw new Error('Failed to delete list'); + + 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(''); + } + + listSelectionOverlay.classList.add('show'); +} + +// Handle save list selection +async function handleSaveListSelection() { + if (!currentLinkForListSelection) 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}/${currentLinkForListSelection}/lists`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ listIds: selectedListIds }) + }); + + const data = await response.json(); + + if (!response.ok) { + throw new Error('Failed to update link lists'); + } + + // Update local array + const linkIndex = allLinks.findIndex(l => l.id === currentLinkForListSelection); + if (linkIndex !== -1) { + allLinks[linkIndex].listIds = selectedListIds; + } + + // Update display + displayLinks(getFilteredLinks()); + + listSelectionOverlay.classList.remove('show'); + currentLinkForListSelection = null; + showMessage('Link lists updated successfully!', 'success'); + } catch (error) { + showMessage('Failed to update link lists', 'error'); + console.error('Error updating link lists:', error); + } +} + +// 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() { + if (allLists.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 = allLists.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'); diff --git a/public/index.html b/public/index.html index 660ca68..54aadf6 100644 --- a/public/index.html +++ b/public/index.html @@ -29,6 +29,16 @@ + + + + +
+ +
+ + +
+
+
+

Manage Lists

+ +
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+
+

Add to Lists

+ +
+
+ +
+ +
+
+