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

@@ -62,7 +62,7 @@ document.addEventListener('DOMContentLoaded', function() {
function openUserModal(user = null) {
document.getElementById('user-modal-title').textContent = user ? 'Edit User' : 'Create User';
document.getElementById('user-id').value = user ? user.id : '';
document.getElementById('user-id').value = (user && user.id) ? user.id : '';
document.getElementById('user-username').value = user ? user.username : '';
document.getElementById('user-username').disabled = !!user;
document.getElementById('user-email').value = user ? user.email : '';
@@ -115,6 +115,7 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.textContent = 'Saving...';
const id = document.getElementById('user-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const data = {
username: document.getElementById('user-username').value,
email: document.getElementById('user-email').value,
@@ -126,7 +127,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (password) data.password = password;
try {
if (id) {
if (isEdit) {
await LinkSync.updateUser(id, data);
} else {
if (!password) {

View File

@@ -3,8 +3,23 @@ document.addEventListener('DOMContentLoaded', function() {
const modal = document.getElementById('collection-modal');
const deleteModal = document.getElementById('delete-collection-modal');
const form = document.getElementById('collection-form');
const typeSelect = document.getElementById('collection-type');
const querySection = document.getElementById('query-builder-section');
const queryInput = document.getElementById('collection-query');
const previewBtn = document.getElementById('preview-query-btn');
const queryStatus = document.getElementById('query-status');
const previewResults = document.getElementById('query-preview-results');
let deleteTargetId = null;
typeSelect.addEventListener('change', function() {
querySection.style.display = this.value === 'dynamic' ? '' : 'none';
if (this.value !== 'dynamic') {
queryInput.value = '';
previewResults.style.display = 'none';
queryStatus.textContent = '';
}
});
async function loadCollections() {
try {
const collections = await LinkSync.getCollections();
@@ -29,6 +44,7 @@ document.addEventListener('DOMContentLoaded', function() {
<span class="badge badge-${col.query_type}">${col.query_type}</span>
<span class="badge ${col.is_public ? 'badge-public' : 'badge-private'}">${col.is_public ? 'Public' : 'Private'}</span>
</div>
${col.query_type === 'dynamic' && col.query_expression ? `<div class="query-hint"><small>${escapeHtml(col.query_expression.expression || '')}</small></div>` : ''}
<div class="actions">
<button class="btn btn-sm btn-outline" data-action="edit" data-id="${col.id}">Edit</button>
<button class="btn btn-sm btn-danger" data-action="delete" data-id="${col.id}">Delete</button>
@@ -46,11 +62,22 @@ document.addEventListener('DOMContentLoaded', function() {
function openCollectionModal(col = null) {
document.getElementById('collection-modal-title').textContent = col ? 'Edit Collection' : 'Create Collection';
document.getElementById('collection-id').value = col ? col.id : '';
document.getElementById('collection-id').value = (col && col.id) ? col.id : '';
document.getElementById('collection-name').value = col ? col.name : '';
document.getElementById('collection-description').value = col ? (col.description || '') : '';
document.getElementById('collection-type').value = col ? col.query_type : 'static';
document.getElementById('collection-public').checked = col ? col.is_public : false;
if (col && col.query_type === 'dynamic' && col.query_expression) {
queryInput.value = col.query_expression.expression || '';
querySection.style.display = '';
} else {
queryInput.value = '';
querySection.style.display = 'none';
}
previewResults.style.display = 'none';
queryStatus.textContent = '';
modal.style.display = 'flex';
}
@@ -72,6 +99,9 @@ document.addEventListener('DOMContentLoaded', function() {
function closeModal() {
modal.style.display = 'none';
form.reset();
querySection.style.display = 'none';
previewResults.style.display = 'none';
queryStatus.textContent = '';
}
function closeDeleteModal() {
@@ -87,6 +117,40 @@ document.addEventListener('DOMContentLoaded', function() {
modal.querySelector('.modal-overlay').addEventListener('click', closeModal);
deleteModal.querySelector('.modal-overlay').addEventListener('click', closeDeleteModal);
previewBtn.addEventListener('click', async function() {
const expression = queryInput.value.trim();
if (!expression) {
queryStatus.textContent = 'Enter a query expression';
queryStatus.style.color = 'var(--warning)';
return;
}
queryStatus.textContent = 'Loading...';
queryStatus.style.color = 'var(--text-muted)';
try {
const results = await LinkSync.previewQuery(expression);
const count = Array.isArray(results) ? results.length : 0;
queryStatus.textContent = `${count} result${count !== 1 ? 's' : ''}`;
queryStatus.style.color = 'var(--success)';
if (count > 0) {
previewResults.innerHTML = `<div class="query-preview-count">${count} matching links:</div>` +
results.slice(0, 10).map(link => `
<div class="query-preview-item">
<a href="${escapeHtml(link.url)}" target="_blank">${escapeHtml(link.title)}</a>
</div>
`).join('') +
(count > 10 ? `<div class="query-preview-count">...and ${count - 10} more</div>` : '');
previewResults.style.display = '';
} else {
previewResults.innerHTML = '<div class="empty-state"><p>No matching links</p></div>';
previewResults.style.display = '';
}
} catch (err) {
queryStatus.textContent = err.message;
queryStatus.style.color = 'var(--error)';
previewResults.style.display = 'none';
}
});
form.addEventListener('submit', async function(e) {
e.preventDefault();
const saveBtn = document.getElementById('collection-save-btn');
@@ -94,15 +158,24 @@ document.addEventListener('DOMContentLoaded', function() {
saveBtn.textContent = 'Saving...';
const id = document.getElementById('collection-id').value;
const isEdit = id && id !== '' && id !== 'undefined';
const queryType = document.getElementById('collection-type').value;
const data = {
name: document.getElementById('collection-name').value,
description: document.getElementById('collection-description').value || null,
query_type: document.getElementById('collection-type').value,
query_type: queryType,
is_public: document.getElementById('collection-public').checked,
};
if (queryType === 'dynamic') {
const expression = queryInput.value.trim();
if (expression) {
data.query_expression = { expression };
}
}
try {
if (id) {
if (isEdit) {
await LinkSync.updateCollection(id, data);
} else {
await LinkSync.createCollection(data);

View File

@@ -5,6 +5,12 @@ document.addEventListener('DOMContentLoaded', async function() {
return;
}
const token = localStorage.getItem('token');
if (token && LinkSync.isTokenExpired(token)) {
LinkSync.logout();
return;
}
document.getElementById('current-user').textContent = user.username;
if (user.role === 'admin') {
@@ -12,16 +18,28 @@ document.addEventListener('DOMContentLoaded', async function() {
}
try {
const [links, collections, keys] = await Promise.all([
const [linksResult, collectionsResult, keysResult] = await Promise.allSettled([
LinkSync.getLinks({ limit: 1 }),
LinkSync.getCollections(),
LinkSync.getApiKeys(),
]);
const links = linksResult.status === 'fulfilled' ? linksResult.value : [];
const collections = collectionsResult.status === 'fulfilled' ? collectionsResult.value : [];
const keys = keysResult.status === 'fulfilled' ? keysResult.value : [];
document.getElementById('link-count').textContent = Array.isArray(links) ? links.length : 0;
document.getElementById('collection-count').textContent = Array.isArray(collections) ? collections.length : 0;
document.getElementById('api-key-count').textContent = Array.isArray(keys) ? keys.length : 0;
} catch (err) {
console.error('Failed to load stats:', err);
}
const expirySeconds = LinkSync.getTokenExpirySeconds(token);
if (expirySeconds > 0 && expirySeconds < 120) {
const warning = document.createElement('div');
warning.className = 'info-message';
warning.textContent = `Session expires in ${Math.ceil(expirySeconds / 60)} minute${expirySeconds > 60 ? 's' : ''}. Save your work.`;
document.querySelector('.dashboard-header').prepend(warning);
}
});

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);

View File

@@ -1,8 +1,40 @@
document.addEventListener("DOMContentLoaded", function () {
const apiBase = "/api";
function isTokenExpired(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp ? now >= payload.exp : false;
} catch {
return true;
}
}
function getTokenExpirySeconds(token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const now = Math.floor(Date.now() / 1000);
return payload.exp ? payload.exp - now : 0;
} catch {
return 0;
}
}
function redirectToLogin() {
localStorage.removeItem("token");
localStorage.removeItem("user");
if (!window.location.pathname.startsWith("/login")) {
window.location.href = "/login?expired=1";
}
}
async function apiFetch(endpoint, options = {}) {
const token = localStorage.getItem("token");
if (token && isTokenExpired(token)) {
redirectToLogin();
throw new Error("Session expired");
}
const headers = {
"Content-Type": "application/json",
...options.headers,
@@ -14,6 +46,10 @@ document.addEventListener("DOMContentLoaded", function () {
...options,
headers,
});
if (response.status === 401) {
redirectToLogin();
throw new Error("Authentication required");
}
if (!response.ok) {
let errorMsg = `HTTP ${response.status}`;
try {
@@ -35,6 +71,8 @@ document.addEventListener("DOMContentLoaded", function () {
window.LinkSync = {
apiFetch,
isTokenExpired,
getTokenExpirySeconds,
async getLinks(params = {}) {
const qs = new URLSearchParams(params).toString();
return apiFetch(`/links/?${qs}`);
@@ -72,6 +110,9 @@ document.addEventListener("DOMContentLoaded", function () {
async deleteCollection(id) {
return apiFetch(`/collections/${id}`, { method: "DELETE" });
},
async previewQuery(expression) {
return apiFetch(`/queries/preview?expression=${encodeURIComponent(expression)}`);
},
async executeQuery(expression, limit = 20) {
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
},
@@ -95,6 +136,7 @@ document.addEventListener("DOMContentLoaded", function () {
logout() {
localStorage.removeItem("token");
localStorage.removeItem("user");
window.location.href = "/login";
},
async getUsers() {
return apiFetch("/admin/users");