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)
This commit is contained in:
DavidSaylor
2026-05-22 07:46:53 -05:00
parent 77b076c7d7
commit fe4cbc3537
29 changed files with 1410 additions and 78 deletions

View File

@@ -4,14 +4,52 @@ document.addEventListener('DOMContentLoaded', function() {
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 {
const links = await LinkSync.getLinks(search ? { search } : {});
renderLinks(Array.isArray(links) ? links : []);
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 = [];
}
}
@@ -62,7 +100,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openModal(link = null) {
document.getElementById('modal-title').textContent = link ? 'Edit Link' : 'Add Link';
document.getElementById('link-id').value = link ? link.id : '';
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 || '') : '';
@@ -98,13 +136,32 @@ document.addEventListener('DOMContentLoaded', function() {
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();
@@ -113,6 +170,7 @@ document.addEventListener('DOMContentLoaded', function() {
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,
@@ -125,13 +183,13 @@ document.addEventListener('DOMContentLoaded', function() {
};
try {
if (id) {
if (isEdit) {
await LinkSync.updateLink(id, data);
} else {
await LinkSync.createLink(data);
}
closeModal();
loadLinks(searchInput.value);
loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) {
alert('Failed to save link: ' + err.message);
} finally {
@@ -140,20 +198,65 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
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(searchInput.value);
loadLinks(searchMode === 'query' ? queryInput.value : searchInput.value);
} catch (err) {
alert('Failed to delete link: ' + err.message);
}
});
document.getElementById('search-btn').addEventListener('click', () => loadLinks(searchInput.value));
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') loadLinks(searchInput.value);
if (e.key === 'Enter') doSearch();
});
queryInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter') doSearch();
});
const urlParams = new URLSearchParams(window.location.search);