diff --git a/config.php b/config.php index 3f6655f..279bda1 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@
installed plugins
-

Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.

+

Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggle multiple plugins and apply them together — the page reloads once when you click Apply.

-
diff --git a/public/js/modules/board.js b/public/js/modules/board.js index 739a8e7..a27d804 100644 --- a/public/js/modules/board.js +++ b/public/js/modules/board.js @@ -584,8 +584,10 @@ export function renderBoard() { renderGroupBar(cards); if (!cards.length) { - board.style.display = ''; - board.innerHTML = `
+ 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 ` +
${esc(p.name)} v${esc(p.version || '?')} ${p.hasBackend ? 'php' : ''} + ${isPending ? `${effectiveEnabled ? 'will enable' : 'will disable'}` : ''}
${p.description ? `
${esc(p.description)}
` : ''}
-
- `).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);