Files
myworkspace/LinkSyncServer/static/js/links-page.js
DavidSaylor fe4cbc3537 feat: add web UI, query engine, session management, and 20 E2E tests
- Web UI: login, dashboard, links CRUD, collections, API keys, admin pages
- Query engine: AND/OR/XOR with field filters, tag search, preview endpoint
- Session management: token expiry detection, 401 interceptor, expiry banner
- Links search: tags included, multi-word AND, query mode with set operations
- Collections: static/dynamic, query builder with preview, public tree view
- Save as Collection: convert search results (static) or query (dynamic)
- Dashboard stats: resilient loading with allSettled pattern
- Login page: redesigned with public collections tree view
- Bug fix: query executor None fields crash (notes/description/url/title)
- E2E tests: 20 Playwright tests covering all critical user flows
- All 104 tests passing (84 unit/integration + 20 E2E)
2026-05-22 07:46:53 -05:00

282 lines
12 KiB
JavaScript

document.addEventListener('DOMContentLoaded', function() {
const linksList = document.getElementById('links-list');
const modal = document.getElementById('link-modal');
const deleteModal = document.getElementById('delete-modal');
const form = document.getElementById('link-form');
const searchInput = document.getElementById('search-input');
const queryInput = document.getElementById('query-input');
const simpleBtn = document.getElementById('simple-search-btn');
const queryBtn = document.getElementById('query-search-btn');
const saveCollectionBtn = document.getElementById('save-collection-btn');
const saveCollectionModal = document.getElementById('save-collection-modal');
const saveCollectionForm = document.getElementById('save-collection-form');
let deleteTargetId = null;
let searchMode = 'simple';
let lastQuery = '';
let lastResults = [];
function setSearchMode(mode) {
searchMode = mode;
simpleBtn.classList.toggle('active', mode === 'simple');
queryBtn.classList.toggle('active', mode === 'query');
searchInput.style.display = mode === 'simple' ? '' : 'none';
queryInput.style.display = mode === 'query' ? '' : 'none';
searchInput.placeholder = mode === 'simple'
? 'Search links by title, URL, tags, or notes...'
: 'e.g. personal AND lan NOT ai';
}
simpleBtn.addEventListener('click', () => setSearchMode('simple'));
queryBtn.addEventListener('click', () => setSearchMode('query'));
async function loadLinks(search = '') {
try {
let links;
if (searchMode === 'query' && search) {
lastQuery = search;
const data = await LinkSync.previewQuery(search);
if (data.error) {
linksList.innerHTML = `<div class="empty-state"><p>Query error: ${escapeHtml(data.error)}</p></div>`;
lastResults = [];
return;
}
links = data.results || [];
} else {
lastQuery = '';
links = await LinkSync.getLinks(search ? { search } : {});
}
lastResults = Array.isArray(links) ? links : [];
renderLinks(lastResults);
} catch (err) {
linksList.innerHTML = `<div class="empty-state"><p>Failed to load links: ${err.message}</p></div>`;
lastResults = [];
}
}
function renderLinks(links) {
if (!links || links.length === 0) {
linksList.innerHTML = '<div class="empty-state"><p>No links found.</p><button class="btn btn-primary" id="empty-add-btn">+ Add your first link</button></div>';
document.getElementById('empty-add-btn').addEventListener('click', openModal);
return;
}
linksList.innerHTML = `
<div class="data-table">
<table>
<thead>
<tr>
<th>Title</th>
<th>URL</th>
<th>Tags</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${links.map(link => `
<tr>
<td>${escapeHtml(link.title)}</td>
<td class="truncate"><a href="${escapeHtml(link.url)}" target="_blank" class="link-url">${escapeHtml(link.url)}</a></td>
<td><div class="tags">${(link.tags || []).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('')}</div></td>
<td>${formatDate(link.created_at)}</td>
<td class="actions">
<button class="btn-icon" data-action="edit" data-id="${link.id}" title="Edit">&#9998;</button>
<button class="btn-icon" data-action="delete" data-id="${link.id}" title="Delete">&#128465;</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
linksList.querySelectorAll('[data-action="edit"]').forEach(btn => {
btn.addEventListener('click', () => editLink(btn.dataset.id));
});
linksList.querySelectorAll('[data-action="delete"]').forEach(btn => {
btn.addEventListener('click', () => confirmDelete(btn.dataset.id));
});
}
function openModal(link = null) {
document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link';
document.getElementById('link-id').value = (link && link.id) ? link.id : '';
document.getElementById('link-url').value = link ? link.url : '';
document.getElementById('link-title').value = link ? link.title : '';
document.getElementById('link-description').value = link ? (link.description || '') : '';
document.getElementById('link-notes').value = link ? (link.notes || '') : '';
document.getElementById('link-tags').value = link ? (link.tags || []).join(', ') : '';
document.getElementById('link-favicon').value = link ? (link.favicon_url || '') : '';
document.getElementById('link-path').value = link ? (link.path || '') : '';
modal.style.display = 'flex';
}
async function editLink(id) {
try {
const links = await LinkSync.getLinks();
const link = (Array.isArray(links) ? links : []).find(l => l.id === id);
if (link) openModal(link);
} catch (err) {
alert('Failed to load link details');
}
}
function confirmDelete(id) {
deleteTargetId = id;
deleteModal.style.display = 'flex';
}
function closeModal() {
modal.style.display = 'none';
form.reset();
}
function closeDeleteModal() {
deleteModal.style.display = 'none';
deleteTargetId = null;
}
function openSaveCollectionModal() {
const currentSearch = searchMode === 'query' ? queryInput.value.trim() : searchInput.value.trim();
const isQueryMode = searchMode === 'query' && currentSearch;
document.getElementById('save-collection-type').value = isQueryMode ? 'dynamic' : 'static';
document.getElementById('save-collection-name').value = isQueryMode ? `Query: ${currentSearch}` : `Search: ${currentSearch}`;
document.getElementById('save-collection-desc').value = '';
document.getElementById('save-collection-public').checked = false;
saveCollectionModal.style.display = 'flex';
}
function closeSaveCollectionModal() {
saveCollectionModal.style.display = 'none';
saveCollectionForm.reset();
}
document.getElementById('new-link-btn').addEventListener('click', () => openModal());
document.getElementById('modal-close').addEventListener('click', closeModal);
document.getElementById('cancel-btn').addEventListener('click', closeModal);
document.getElementById('delete-cancel-btn').addEventListener('click', closeDeleteModal);
document.getElementById('save-collection-btn').addEventListener('click', openSaveCollectionModal);
document.getElementById('save-collection-modal-close').addEventListener('click', closeSaveCollectionModal);
document.getElementById('save-collection-cancel-btn').addEventListener('click', closeSaveCollectionModal);
modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
saveCollectionModal.querySelector('.modal-overlay').addEventListener('click', closeSaveCollectionModal);
form.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const id = document.getElementById('link-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const tagsRaw = document.getElementById('link-tags').value;
const data = {
url: document.getElementById('link-url').value,
title: document.getElementById('link-title').value,
description: document.getElementById('link-description').value || null,
notes: document.getElementById('link-notes').value || null,
tags: tagsRaw ? tagsRaw.split(',').map(t => t.trim()).filter(t => t) : [],
favicon_url: document.getElementById('link-favicon').value || null,
path: document.getElementById('link-path').value || null,
};
try {
if (isEdit) {
await LinkSync.updateLink(id, data);
} else {
await LinkSync.createLink(data);
}
closeModal();
loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) {
alert('Failed to save link: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
saveCollectionForm.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('save-collection-save-btn');
saveBtn.disabled = true;
saveBtn.textContent = 'Saving...';
const name = document.getElementById('save-collection-name').value;
const desc = document.getElementById('save-collection-desc').value || null;
const type = document.getElementById('save-collection-type').value;
const isPublic = document.getElementById('save-collection-public').checked;
const data = {
name,
description: desc,
query_type: type,
is_public: isPublic,
};
if (type === 'dynamic') {
const expression = searchMode === 'query' ? queryInput.value.trim() : searchInput.value.trim();
data.query_expression = { expression };
} else {
data.link_ids = lastResults.map(l => l.id);
}
try {
await LinkSync.createCollection(data);
closeSaveCollectionModal();
alert('Collection saved successfully');
} catch (err) {
alert('Failed to save collection: ' + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Save';
}
});
document.getElementById('confirm-delete-btn').addEventListener('click', async function() {
if (!deleteTargetId) return;
try {
await LinkSync.deleteLink(deleteTargetId);
closeDeleteModal();
loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) {
alert('Failed to delete link: ' + err.message);
}
});
function doSearch() {
const search = searchMode === 'query' ? queryInput.value : searchInput.value;
loadLinks(search);
}
document.getElementById('search-btn').addEventListener('click', doSearch);
searchInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') doSearch();
});
queryInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') doSearch();
});
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('action') === 'new') {
openModal();
}
loadLinks();
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString();
}
});