fixes: tag assign stale body, board placeholder, plugin pending, bucket activate
- Context menu tag toggle reads fresh body from state.cards — was overwriting with stale snapshot, dropping existing tags (e.g. #done) - Board empty-state centered with flex (vertical + horizontal) - Plugin settings: batch toggles via Apply button, single reload instead of one reload per toggle - New bucket creation activates it directly on the board Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3cca14a3c6
commit
002562b1d3
6 files changed
+105
-70
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
define('APP_NAME', 'hyprcrd');
|
define('APP_NAME', 'hyprcrd');
|
||||||
define('APP_VERSION', '1.4.2');
|
define('APP_VERSION', '1.4.3');
|
||||||
|
|
||||||
// Database path — writable directory, outside webroot
|
// Database path — writable directory, outside webroot
|
||||||
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
// DB_PATH / UPLOAD_DIR can be pre-defined by the Electron router for app isolation
|
||||||
|
|||||||
+6
-5
@@ -1888,12 +1888,13 @@ if (str_starts_with($route, '/api/')) {
|
|||||||
<div id="settings-panel-plugins" class="hidden space-y-4">
|
<div id="settings-panel-plugins" class="hidden space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
|
<div class="font-pixel text-[9px] uppercase tracking-wider text-t-dim mb-2"><i class="ph ph-puzzle-piece text-xs"></i> installed plugins</div>
|
||||||
<p class="text-[10px] text-t-dim leading-relaxed mb-3">Plugins extend hyprcrd with optional features. Disable a plugin to skip loading its assets entirely. Toggling requires a page reload to take effect.</p>
|
<p class="text-[10px] text-t-dim leading-relaxed mb-3">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.</p>
|
||||||
<div id="plugins-list" class="space-y-2"></div>
|
<div id="plugins-list" class="space-y-2"></div>
|
||||||
<div id="plugins-reload-notice" class="hidden mt-3 flex items-center gap-2 px-3 py-2 border border-t-accent bg-t-accent/10 text-xs text-t-accent">
|
<div id="plugins-apply-bar" class="hidden mt-3 flex items-center gap-3 px-3 py-2 border border-t-accent bg-t-accent/10">
|
||||||
<i class="ph ph-warning text-sm"></i>
|
<i class="ph ph-warning text-sm text-t-accent"></i>
|
||||||
<span class="flex-1 font-pixel text-[10px]">reload required to apply changes</span>
|
<span id="plugins-apply-count" class="flex-1 font-pixel text-[10px] text-t-accent"></span>
|
||||||
<button id="plugins-reload-btn" class="font-pixel text-[10px] underline hover:text-t-bright">Reload now</button>
|
<button id="plugins-discard-btn" class="font-pixel text-[10px] text-t-muted hover:text-t-danger transition-colors">discard</button>
|
||||||
|
<button id="plugins-apply-btn" class="font-pixel text-[10px] px-3 py-1 bg-t-accent text-t-bg hover:brightness-110 transition-all">Apply</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -584,8 +584,10 @@ export function renderBoard() {
|
|||||||
renderGroupBar(cards);
|
renderGroupBar(cards);
|
||||||
|
|
||||||
if (!cards.length) {
|
if (!cards.length) {
|
||||||
board.style.display = '';
|
board.style.display = 'flex';
|
||||||
board.innerHTML = `<div class="col-span-full text-center py-20">
|
board.style.alignItems = 'center';
|
||||||
|
board.style.justifyContent = 'center';
|
||||||
|
board.innerHTML = `<div class="flex flex-col items-center text-center">
|
||||||
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
<i class="ph ph-cards text-4xl text-t-dim mb-4 block"></i>
|
||||||
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
<p class="font-pixel text-sm text-t-dim cursor-blink">no cards found</p></div>`;
|
||||||
const fc = $('footer-count');
|
const fc = $('footer-count');
|
||||||
@@ -707,6 +709,8 @@ export function renderBoard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
board.style.display = '';
|
board.style.display = '';
|
||||||
|
board.style.alignItems = '';
|
||||||
|
board.style.justifyContent = '';
|
||||||
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
board.innerHTML = cards.map(c => renderCardHtml(c, inGroup, recent)).join('');
|
||||||
|
|
||||||
bindCardEvents();
|
bindCardEvents();
|
||||||
|
|||||||
@@ -608,8 +608,13 @@ function renderBar() {
|
|||||||
}
|
}
|
||||||
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
const name = await showDialog({ title: 'new_bucket', input: true, placeholder: '> bucket name...' });
|
||||||
if (!name || !name.trim()) return;
|
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();
|
await loadBuckets();
|
||||||
|
if (created?.id) {
|
||||||
|
setActiveBucket(created.id);
|
||||||
|
state.activeGroup = null;
|
||||||
|
}
|
||||||
|
renderBar();
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -195,36 +195,44 @@ export function showContextMenu(x, y, card) {
|
|||||||
</button>` : '');
|
</button>` : '');
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag]').forEach(btn => btn.addEventListener('click', async e => {
|
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 tag = btn.dataset.ctxTag;
|
||||||
const has = cardTags.includes(tag);
|
// Re-read the current card from state to avoid stale body
|
||||||
let newBody = card.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) {
|
if (has) {
|
||||||
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
newBody = newBody.replace(new RegExp('\\s*#' + tag.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&') + '(?=\\s|$)', 'm'), '');
|
||||||
cardTags.splice(cardTags.indexOf(tag), 1);
|
|
||||||
} else {
|
} else {
|
||||||
newBody = newBody.trimEnd() + ' #' + tag;
|
newBody = newBody.trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
|
||||||
}
|
}
|
||||||
card.body = newBody;
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
// Trust the server's response for the new tags string
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
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);
|
renderCtxTags(tagInput.value);
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tagList.querySelectorAll('[data-ctx-tag-new]').forEach(btn => btn.addEventListener('click', async e => {
|
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 tag = btn.dataset.ctxTagNew;
|
||||||
const newBody = (card.body || '').trimEnd() + ' #' + tag;
|
const fresh = state.cards.find(x => x.id == targetId) || card;
|
||||||
card.body = newBody;
|
const newBody = (fresh.body || '').trimEnd() + ' #' + tag;
|
||||||
cardTags.push(tag);
|
const updated = await api(`api/cards/${targetId}`, { method: 'PATCH', body: { body: newBody } });
|
||||||
allTags.push(tag);
|
const c = state.cards.find(x => x.id == targetId);
|
||||||
allTags.sort();
|
if (c) { c.body = updated?.body ?? newBody; c.tags = updated?.tags ?? ''; }
|
||||||
await api(`api/cards/${card.id}`, { method: 'PATCH', body: { body: newBody } });
|
card.body = c?.body ?? newBody;
|
||||||
const c = state.cards.find(x => x.id == card.id);
|
cardTags.length = 0;
|
||||||
if (c) { c.body = newBody; c.tags = cardTags.join(','); }
|
cardTags.push(...parseTags(c || { tags: updated?.tags || '' }));
|
||||||
|
if (!allTags.includes(tag)) { allTags.push(tag); allTags.sort(); }
|
||||||
tagInput.value = '';
|
tagInput.value = '';
|
||||||
renderCtxTags('');
|
renderCtxTags('');
|
||||||
window._renderBoard();
|
window._renderBoard();
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
// ── Plugin Settings UI ──
|
// ── Plugin Settings UI ──
|
||||||
// Renders the list of installed plugins with on/off toggles in the
|
// Toggles collect pending changes client-side and commit together via
|
||||||
// settings overlay. Disabled plugins are skipped at PHP render time;
|
// the Apply button. Only reloads once all changes are committed.
|
||||||
// toggling requires a reload to take effect (page must re-fetch the
|
|
||||||
// new script/css/html tag set).
|
|
||||||
|
|
||||||
import { $, esc, api } from './util.js';
|
import { $, esc, api } from './util.js';
|
||||||
|
|
||||||
let pluginsCache = null;
|
let pluginsCache = null;
|
||||||
let dirtyAfterToggle = false;
|
let pending = new Map(); // name -> desired enabled state
|
||||||
|
|
||||||
async function fetchPlugins() {
|
async function fetchPlugins() {
|
||||||
const res = await fetch('api/plugins');
|
const res = await fetch('api/plugins');
|
||||||
@@ -23,63 +21,86 @@ function render(list) {
|
|||||||
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
container.innerHTML = '<p class="text-xs text-t-dim italic">No plugins installed. Drop a plugin folder into <code>public/plugins/</code> to add one.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = list.map(p => `
|
container.innerHTML = list.map(p => {
|
||||||
<div class="flex items-start gap-3 px-3 py-2 border border-t-border bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
const effectiveEnabled = pending.has(p.name) ? pending.get(p.name) : p.enabled;
|
||||||
|
const isPending = pending.has(p.name);
|
||||||
|
return `
|
||||||
|
<div class="flex items-start gap-3 px-3 py-2 border ${isPending ? 'border-t-accent' : 'border-t-border'} bg-t-bg rounded-sm" data-plugin="${esc(p.name)}">
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
<span class="font-pixel text-[11px] text-t-bright">${esc(p.name)}</span>
|
||||||
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
<span class="font-pixel text-[9px] text-t-dim">v${esc(p.version || '?')}</span>
|
||||||
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
${p.hasBackend ? '<span class="font-pixel text-[8px] px-1 py-0.5 border border-t-border text-t-muted uppercase">php</span>' : ''}
|
||||||
|
${isPending ? `<span class="font-pixel text-[8px] px-1 py-0.5 text-t-accent border border-t-accent uppercase">${effectiveEnabled ? 'will enable' : 'will disable'}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
${p.description ? `<div class="text-[10px] text-t-muted leading-snug mt-1">${esc(p.description)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
<label class="flex items-center cursor-pointer shrink-0 mt-0.5">
|
||||||
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${p.enabled ? 'checked' : ''} data-name="${esc(p.name)}">
|
<input type="checkbox" class="plugin-toggle accent-t-accent w-4 h-4" ${effectiveEnabled ? 'checked' : ''} data-name="${esc(p.name)}" data-original="${p.enabled ? '1' : '0'}">
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
|
||||||
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
container.querySelectorAll('.plugin-toggle').forEach(input => {
|
||||||
input.addEventListener('change', onToggle);
|
input.addEventListener('change', onToggle);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateApplyBar();
|
||||||
}
|
}
|
||||||
|
|
||||||
let reloadTimer = null;
|
function onToggle(e) {
|
||||||
|
|
||||||
async function onToggle(e) {
|
|
||||||
const input = e.currentTarget;
|
const input = e.currentTarget;
|
||||||
const name = input.dataset.name;
|
const name = input.dataset.name;
|
||||||
const enabled = input.checked;
|
const original = input.dataset.original === '1';
|
||||||
input.disabled = true;
|
const desired = input.checked;
|
||||||
try {
|
if (desired === original) {
|
||||||
const res = await api('api/plugins/toggle', {
|
pending.delete(name);
|
||||||
method: 'POST',
|
} else {
|
||||||
body: { name, enabled },
|
pending.set(name, desired);
|
||||||
});
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
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() {
|
export async function openPluginSettings() {
|
||||||
const container = $('plugins-list');
|
const container = $('plugins-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
container.innerHTML = '<p class="text-xs text-t-dim italic">Loading...</p>';
|
||||||
if (!dirtyAfterToggle) {
|
|
||||||
$('plugins-reload-notice')?.classList.add('hidden');
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const list = await fetchPlugins();
|
const list = await fetchPlugins();
|
||||||
render(list);
|
render(list);
|
||||||
@@ -88,9 +109,5 @@ export async function openPluginSettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wire reload button once at module load
|
$('plugins-apply-btn')?.addEventListener('click', applyChanges);
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
$('plugins-discard-btn')?.addEventListener('click', discardChanges);
|
||||||
$('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());
|
|
||||||
Reference in new issue
Block a user