+ board.style.display = 'flex';
+ board.style.alignItems = 'center';
+ board.style.justifyContent = 'center';
+ board.innerHTML = `
`;
const fc = $('footer-count');
@@ -707,6 +709,8 @@ export function renderBoard() {
}
board.style.display = '';
+ board.style.alignItems = '';
+ board.style.justifyContent = '';
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
bindCardEvents();
diff --git a/public/js/modules/buckets.js b/public/js/modules/buckets.js
index 596bda0..3a2b35e 100644
--- a/public/js/modules/buckets.js
+++ b/public/js/modules/buckets.js
@@ -608,8 +608,13 @@ function renderBar() {
}
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
if (!name || !name.trim()) return;
- await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
+ const created = await api('api/buckets', { method: 'POST', body: { name: name.trim() } });
await loadBuckets();
+ if (created?.id) {
+ setActiveBucket(created.id);
+ state.activeGroup = null;
+ }
+ renderBar();
window._renderBoard();
});
diff --git a/public/js/modules/context-menu.js b/public/js/modules/context-menu.js
index 93f06c7..0663b84 100644
--- a/public/js/modules/context-menu.js
+++ b/public/js/modules/context-menu.js
@@ -195,36 +195,44 @@ export function showContextMenu(x, y, card) {
` : '');
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
- e.stopPropagation();
+ e.stopPropagation(); e.preventDefault();
+ const targetId = card.id;
const tag = btn.dataset.ctxTag;
- const has = cardTags.includes(tag);
- let newBody = card.body || '';
+ // Re-read the current card from state to avoid stale body
+ const fresh = state.cards.find(x => x.id == targetId) || card;
+ const freshTags = parseTags(fresh);
+ const has = freshTags.includes(tag);
+ let newBody = fresh.body || '';
if (has) {
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
- cardTags.splice(cardTags.indexOf(tag), 1);
} else {
newBody = newBody.trimEnd() + ' #' + tag;
- cardTags.push(tag);
}
- card.body = newBody;
- await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
- const c = state.cards.find(x => x.id == card.id);
- if (c) { c.body = newBody; c.tags = cardTags.join(','); }
+ const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
+ // Trust the server's response for the new tags string
+ const c = state.cards.find(x => x.id == targetId);
+ if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
+ // Update our local working copy
+ card.body = c?.body ?? newBody;
+ cardTags.length = 0;
+ cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
renderCtxTags(tagInput.value);
window._renderBoard();
}));
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
- e.stopPropagation();
+ e.stopPropagation(); e.preventDefault();
+ const targetId = card.id;
const tag = btn.dataset.ctxTagNew;
- const newBody = (card.body || '').trimEnd() + ' #' + tag;
- card.body = newBody;
- cardTags.push(tag);
- allTags.push(tag);
- allTags.sort();
- await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
- const c = state.cards.find(x => x.id == card.id);
- if (c) { c.body = newBody; c.tags = cardTags.join(','); }
+ const fresh = state.cards.find(x => x.id == targetId) || card;
+ const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
+ const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
+ const c = state.cards.find(x => x.id == targetId);
+ if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
+ card.body = c?.body ?? newBody;
+ cardTags.length = 0;
+ cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
+ if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
tagInput.value = '';
renderCtxTags('');
window._renderBoard();
diff --git a/public/js/modules/plugin-settings.js b/public/js/modules/plugin-settings.js
index 10dea3c..2cdfb6f 100644
--- a/public/js/modules/plugin-settings.js
+++ b/public/js/modules/plugin-settings.js
@@ -1,13 +1,11 @@
// ── Plugin Settings UI ──
-// Renders the list of installed plugins with on/off toggles in the
-// settings overlay. Disabled plugins are skipped at PHP render time;
-// toggling requires a reload to take effect (page must re-fetch the
-// new script/css/html tag set).
+// Toggles collect pending changes client-side and commit together via
+// the Apply button. Only reloads once all changes are committed.
import { $, esc, api } from './util.js';
let pluginsCache = null;
-let dirtyAfterToggle = false;
+let pending = new Map(); // name -> desired enabled state
async function fetchPlugins() {
const res = await fetch('api/plugins');
@@ -23,63 +21,86 @@ function render(list) {
container.innerHTML = '
No plugins installed. Drop a plugin folder into public/plugins/ to add one.
';
return;
}
- container.innerHTML = list.map(p => `
-
+ container.innerHTML = list.map(p => {
+ const effectiveEnabled = pending.has(p.name) ? pending.get(p.name) : p.enabled;
+ const isPending = pending.has(p.name);
+ return `
+
- `).join('');
+
`;
+ }).join('');
container.querySelectorAll('.plugin-toggle').forEach(input => {
input.addEventListener('change', onToggle);
});
+
+ updateApplyBar();
}
-let reloadTimer = null;
-
-async function onToggle(e) {
+function onToggle(e) {
const input = e.currentTarget;
const name = input.dataset.name;
- const enabled = input.checked;
- input.disabled = true;
- try {
- const res = await api('api/plugins/toggle', {
- method: 'POST',
- body: { name, enabled },
- });
- if (res.error) throw new Error(res.error);
- pluginsCache = res.plugins;
- dirtyAfterToggle = true;
- $('plugins-reload-notice')?.classList.remove('hidden');
- // Auto-reload so the effect of the toggle is visible immediately.
- // Debounced so rapid successive toggles only trigger one reload.
- if (reloadTimer) clearTimeout(reloadTimer);
- reloadTimer = setTimeout(() => location.reload(), 400);
- } catch (err) {
- // Revert UI on failure
- input.checked = !enabled;
- console.error('[plugins] toggle failed:', err);
- } finally {
- input.disabled = false;
+ const original = input.dataset.original === '1';
+ const desired = input.checked;
+ if (desired === original) {
+ pending.delete(name);
+ } else {
+ pending.set(name, desired);
}
+ render(pluginsCache); // re-render to update badge and border
+}
+
+function updateApplyBar() {
+ const bar = $('plugins-apply-bar');
+ if (!bar) return;
+ if (pending.size === 0) {
+ bar.classList.add('hidden');
+ return;
+ }
+ bar.classList.remove('hidden');
+ const count = pending.size;
+ const label = $('plugins-apply-count');
+ if (label) label.textContent = `${count} pending change${count !== 1 ? 's' : ''}`;
+}
+
+async function applyChanges() {
+ if (pending.size === 0) return;
+ const btn = $('plugins-apply-btn');
+ if (btn) { btn.disabled = true; btn.textContent = 'applying...'; }
+ try {
+ for (const [name, enabled] of pending) {
+ const res = await api('api/plugins/toggle', { method: 'POST', body: { name, enabled } });
+ if (res.error) throw new Error(`${name}: ${res.error}`);
+ }
+ pending.clear();
+ // Reload so the new plugin set takes effect
+ location.reload();
+ } catch (err) {
+ if (btn) { btn.disabled = false; btn.textContent = 'Apply'; }
+ alert('Failed to apply: ' + err.message);
+ }
+}
+
+function discardChanges() {
+ pending.clear();
+ render(pluginsCache);
}
export async function openPluginSettings() {
const container = $('plugins-list');
if (!container) return;
container.innerHTML = '
Loading...
';
- if (!dirtyAfterToggle) {
- $('plugins-reload-notice')?.classList.add('hidden');
- }
try {
const list = await fetchPlugins();
render(list);
@@ -88,9 +109,5 @@ export async function openPluginSettings() {
}
}
-// Wire reload button once at module load
-document.addEventListener('DOMContentLoaded', () => {
- $('plugins-reload-btn')?.addEventListener('click', () => location.reload());
-}, { once: true });
-// In case DOMContentLoaded already fired (modules are deferred but safe to retry)
-$('plugins-reload-btn')?.addEventListener('click', () => location.reload());
+$('plugins-apply-btn')?.addEventListener('click', applyChanges);
+$('plugins-discard-btn')?.addEventListener('click', discardChanges);