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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user