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