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

@@ -170,10 +170,45 @@ body {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
align-items: center;
flex-wrap: wrap;
}
.search-mode-toggle {
display: flex;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.search-mode-btn {
border-radius: 0;
border: none;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
background: var(--surface);
color: var(--text-muted);
}
.search-mode-btn.active {
background: var(--primary);
color: white;
}
.search-mode-btn:first-child {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.search-mode-btn:last-child {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.search-bar input {
flex: 1;
min-width: 200px;
padding: 0.625rem 1rem;
border: 1px solid var(--border);
border-radius: var(--radius);
@@ -214,6 +249,55 @@ body {
resize: vertical;
}
.form-hint {
display: block;
color: var(--text-muted);
font-size: 0.75rem;
margin-top: 0.25rem;
}
.query-status {
font-size: 0.75rem;
color: var(--text-muted);
align-self: center;
margin-left: 0.5rem;
}
.query-preview {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
max-height: 200px;
overflow-y: auto;
margin-bottom: 1rem;
}
.query-preview-item {
padding: 0.375rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid var(--border);
}
.query-preview-item:last-child {
border-bottom: none;
}
.query-preview-item a {
color: var(--primary);
text-decoration: none;
}
.query-preview-item a:hover {
text-decoration: underline;
}
.query-preview-count {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.checkbox-group label {
display: flex;
align-items: center;
@@ -241,6 +325,15 @@ body {
font-size: 0.875rem;
}
.info-message {
background: #eff6ff;
color: var(--primary);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
font-size: 0.875rem;
}
.success-message {
background: #f0fdf4;
color: var(--success);
@@ -300,6 +393,186 @@ body {
margin-bottom: 2rem;
}
/* Public Page (Login + Public Links Tree) */
.public-page {
background: var(--bg);
min-height: 100vh;
}
.public-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
position: sticky;
top: 0;
z-index: 100;
}
.public-header-inner {
max-width: 1200px;
margin: 0 auto;
padding: 0.75rem 1rem;
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
}
.public-brand h1 {
font-size: 1.25rem;
color: var(--primary);
white-space: nowrap;
}
.public-login-form {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.public-login-form input {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
min-width: 140px;
}
.public-login-form .error-message,
.public-login-form .info-message {
width: 100%;
margin-bottom: 0;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.public-main {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
.public-main h2 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: var(--text);
}
/* Public Tree View */
.public-tree {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tree-collection {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem 1rem;
}
.tree-toggle {
background: none;
border: none;
cursor: pointer;
font-size: 0.75rem;
padding: 0;
margin-right: 0.5rem;
color: var(--text-muted);
width: 1rem;
text-align: center;
}
.tree-toggle:disabled {
cursor: default;
opacity: 0.4;
}
.tree-collection-name {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text);
}
.tree-meta {
font-size: 0.75rem;
color: var(--text-muted);
margin-left: 0.5rem;
}
.tree-desc {
display: block;
font-size: 0.8125rem;
color: var(--text-muted);
margin: 0.25rem 0 0 1.5rem;
}
.tree-links {
margin-top: 0.5rem;
margin-left: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.tree-link {
padding: 0.375rem 0.5rem;
border-radius: 4px;
font-size: 0.8125rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.tree-link:hover {
background: var(--bg);
}
.tree-link-title {
color: var(--primary);
text-decoration: none;
font-weight: 500;
}
.tree-link-title:hover {
text-decoration: underline;
}
.tree-link-url {
color: var(--text-muted);
font-size: 0.75rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.tree-link .tags {
display: inline-flex;
gap: 0.25rem;
}
@media (max-width: 640px) {
.public-header-inner {
flex-direction: column;
align-items: stretch;
}
.public-login-form {
flex-direction: column;
}
.public-login-form input {
min-width: auto;
width: 100%;
}
.public-login-form .btn {
width: 100%;
}
}
/* Dashboard */
.dashboard-header {
margin-bottom: 2rem;
@@ -507,6 +780,16 @@ body {
margin-bottom: 0.75rem;
}
.collection-card .query-hint {
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
font-family: "Fira Code", "Cascadia Code", monospace;
background: var(--bg);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.collection-card .badge {
display: inline-block;
padding: 0.125rem 0.5rem;

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