diff --git a/public/app.js b/public/app.js index f579e25..918cf40 100644 --- a/public/app.js +++ b/public/app.js @@ -6,11 +6,13 @@ const linkInput = document.getElementById('linkInput'); const addButton = document.getElementById('addButton'); const searchInput = document.getElementById('searchInput'); const linksContainer = document.getElementById('linksContainer'); -const messageDiv = document.getElementById('message'); +const toastContainer = document.getElementById('toastContainer'); +const archiveToggle = document.getElementById('archiveToggle'); // State let allLinks = []; let searchTimeout = null; +let showArchived = false; // Initialize app document.addEventListener('DOMContentLoaded', () => { @@ -22,6 +24,7 @@ document.addEventListener('DOMContentLoaded', () => { function setupEventListeners() { linkForm.addEventListener('submit', handleAddLink); searchInput.addEventListener('input', handleSearch); + archiveToggle.addEventListener('click', handleToggleArchive); } // Load all links @@ -31,13 +34,26 @@ async function loadLinks() { if (!response.ok) throw new Error('Failed to load links'); allLinks = await response.json(); - displayLinks(allLinks); + // 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(); @@ -67,9 +83,11 @@ async function handleAddLink(e) { 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(allLinks); + displayLinks(getFilteredLinks()); // Clear input linkInput.value = ''; @@ -93,7 +111,7 @@ function handleSearch(e) { clearTimeout(searchTimeout); searchTimeout = setTimeout(async () => { if (!query) { - displayLinks(allLinks); + displayLinks(getFilteredLinks()); return; } @@ -102,7 +120,12 @@ function handleSearch(e) { if (!response.ok) throw new Error('Search failed'); const filteredLinks = await response.json(); - displayLinks(filteredLinks); + // 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); @@ -110,12 +133,34 @@ function handleSearch(e) { }, 300); } +// Handle archive toggle +function handleToggleArchive() { + showArchived = !showArchived; + const toggleWrapper = archiveToggle.closest('.archive-toggle-wrapper'); + archiveToggle.classList.toggle('active', showArchived); + toggleWrapper.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. ${allLinks.length === 0 ? 'Add your first link to get started!' : 'Try a different search term.'}

+

No links found. ${message}

`; return; @@ -130,6 +175,14 @@ function displayLinks(links) { 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 @@ -158,13 +211,117 @@ function createLinkCard(link) { `; } +// 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?')) { @@ -180,7 +337,7 @@ async function handleDeleteLink(id) { // Remove from local array allLinks = allLinks.filter(link => link.id !== id); - displayLinks(allLinks); + displayLinks(getFilteredLinks()); showMessage('Link deleted successfully', 'success'); } catch (error) { showMessage('Failed to delete link', 'error'); @@ -188,14 +345,28 @@ async function handleDeleteLink(id) { } } -// Show message +// Show toast message function showMessage(text, type = 'success') { - messageDiv.textContent = text; - messageDiv.className = `message ${type}`; - messageDiv.style.display = 'block'; + 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(() => { - messageDiv.style.display = 'none'; + toast.classList.remove('show'); + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); // Wait for fade-out animation }, 3000); } diff --git a/public/index.html b/public/index.html index 5e67557..33a8dfc 100644 --- a/public/index.html +++ b/public/index.html @@ -27,6 +27,12 @@ autocomplete="off" > +
+ + Archive +
@@ -60,8 +66,6 @@ - - +
+ diff --git a/public/styles.css b/public/styles.css index 056e140..1bdff80 100644 --- a/public/styles.css +++ b/public/styles.css @@ -89,6 +89,77 @@ body { flex: 1; justify-content: flex-end; max-width: 400px; + gap: 0.75rem; +} + +.archive-toggle-wrapper { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.toggle-label { + font-size: 0.85rem; + font-weight: 500; + color: var(--text-secondary); + transition: color 0.3s ease; + user-select: none; +} + +.archive-toggle-wrapper.active .toggle-label { + color: var(--text-primary); + font-weight: 600; +} + +.archive-toggle-wrapper:not(.active) .toggle-label { + color: var(--text-secondary); + font-weight: 500; +} + +.archive-toggle { + position: relative; + width: 50px; + height: 26px; + background: var(--surface-light); + border: 1px solid var(--border); + border-radius: 13px; + cursor: pointer; + transition: all 0.3s ease; + padding: 0; + outline: none; + flex-shrink: 0; +} + +.archive-toggle:hover { + border-color: var(--secondary-color); + box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.1); +} + +.archive-toggle.active { + background: var(--secondary-color); + border-color: var(--secondary-color); +} + +.archive-toggle.active:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +.toggle-slider { + position: absolute; + top: 2px; + left: 2px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.3s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.archive-toggle.active .toggle-slider { + transform: translateX(24px); } .container { @@ -257,23 +328,51 @@ body { font-weight: 400; } -.message { - padding: 1rem; - border-radius: 8px; - margin-bottom: 1.5rem; - animation: slideDown 0.3s ease-out; +/* Toast Container */ +.toast-container { + position: fixed; + top: 1rem; + right: 1rem; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 0.75rem; + pointer-events: none; + align-items: flex-end; } -.message.success { - background: rgba(16, 185, 129, 0.2); +.toast { + padding: 0.875rem 1.25rem; + border-radius: 12px; + font-size: 0.9rem; + font-weight: 500; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(10px); + opacity: 0; + transform: translateX(400px); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + pointer-events: auto; + word-wrap: break-word; + white-space: nowrap; + width: fit-content; + max-width: calc(100vw - 2rem); +} + +.toast.show { + opacity: 1; + transform: translateX(0); +} + +.toast-success { + background: rgba(16, 185, 129, 0.95); border: 1px solid var(--success); - color: var(--success); + color: white; } -.message.error { - background: rgba(239, 68, 68, 0.2); +.toast-error { + background: rgba(239, 68, 68, 0.95); border: 1px solid var(--error); - color: var(--error); + color: white; } .links-container { @@ -380,11 +479,34 @@ body { border-top: 1px solid var(--border); } +.link-actions { + display: flex; + gap: 0.5rem; + align-items: center; +} + .link-date { color: var(--text-muted); font-size: 0.85rem; } +.archive-btn { + background: rgba(139, 92, 246, 0.2); + color: var(--secondary-color); + border: 1px solid var(--secondary-color); + padding: 0.5rem 1rem; + border-radius: 6px; + font-size: 0.85rem; + cursor: pointer; + transition: all 0.3s ease; + white-space: nowrap; +} + +.archive-btn:hover { + background: var(--secondary-color); + color: white; +} + .delete-btn { background: rgba(239, 68, 68, 0.2); color: var(--error); @@ -394,6 +516,7 @@ body { font-size: 0.85rem; cursor: pointer; transition: all 0.3s ease; + white-space: nowrap; } .delete-btn:hover { @@ -433,6 +556,18 @@ body { } @media (max-width: 768px) { + .toast-container { + top: 0.75rem; + right: 0.75rem; + left: 0.75rem; + align-items: flex-end; + } + + .toast { + white-space: normal; + max-width: 100%; + } + .top-header-bar { padding: 0.75rem 0; } @@ -457,10 +592,29 @@ body { .header-right { max-width: 100%; + flex-direction: row; + gap: 0.75rem; + flex-wrap: wrap; } .search-wrapper { - min-width: 100%; + flex: 1; + min-width: 200px; + } + + .archive-toggle-wrapper { + flex-shrink: 0; + } + + .link-actions { + flex-wrap: wrap; + gap: 0.5rem; + } + + .archive-btn, + .delete-btn { + flex: 1; + min-width: 80px; } .input-wrapper { @@ -477,3 +631,19 @@ body { } } +@media (max-width: 400px) { + .header-right { + flex-direction: column; + gap: 0.75rem; + } + + .search-wrapper { + min-width: 100%; + } + + .archive-toggle-wrapper { + width: 100%; + justify-content: flex-start; + } +} + diff --git a/server.js b/server.js index 3cfaff4..8298976 100644 --- a/server.js +++ b/server.js @@ -642,6 +642,32 @@ app.post('/api/links', async (req, res) => { } }); +// Archive/Unarchive a link +app.patch('/api/links/:id/archive', async (req, res) => { + try { + const { id } = req.params; + const { archived } = req.body; + + if (typeof archived !== 'boolean') { + return res.status(400).json({ error: 'archived must be a boolean' }); + } + + const links = await readLinks(); + const linkIndex = links.findIndex(link => link.id === id); + + if (linkIndex === -1) { + return res.status(404).json({ error: 'Link not found' }); + } + + links[linkIndex].archived = archived; + await writeLinks(links); + + res.json(links[linkIndex]); + } catch (error) { + res.status(500).json({ error: 'Failed to update link' }); + } +}); + // Delete a link app.delete('/api/links/:id', async (req, res) => { try {