diff --git a/gitea-issue-client b/gitea-issue-client new file mode 160000 index 0000000..3d0c2f3 --- /dev/null +++ b/gitea-issue-client @@ -0,0 +1 @@ +Subproject commit 3d0c2f3a82495df44e5697569694b4f21c4f6853 diff --git a/public/index.php b/public/index.php index 954bd80..cf52cfb 100644 --- a/public/index.php +++ b/public/index.php @@ -239,6 +239,10 @@ if (str_starts_with($route, '/api/')) { return implode(',', array_unique(array_map('strtolower', $matches[1] ?? []))); } + // Plugin route handlers (registered by plugin backend.php files). + // Each handler is responsible for calling exit() on a match. + PluginManager::instance()->dispatchRoute($route, $method); + // Cards if ($route === '/api/cards' && $method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); diff --git a/public/js/app.js b/public/js/app.js index 577b3f0..a7752b9 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -170,7 +170,7 @@ document.getElementById('logout-btn')?.addEventListener('click', async () => { async function checkAuth() { const res = await fetch('api/auth/status'); const data = await res.json(); - if (data.csrf) setCsrf(data.csrf); + if (data.csrf) { setCsrf(data.csrf); window.hyprcrd?.util?.setCsrf?.(data.csrf); } if (data.setup) { let pw = null; @@ -181,7 +181,7 @@ async function checkAuth() { const r = await fetch('api/auth/setup', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); if (d.error) { location.reload(); return; } - if (d.csrf) setCsrf(d.csrf); + if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } else if (!data.logged_in) { let ok = false; while (!ok) { @@ -189,7 +189,8 @@ async function checkAuth() { if (!pw) continue; const r = await fetch('api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); const d = await r.json(); - if (d.ok) { ok = true; if (d.csrf) setCsrf(d.csrf); } + if (d.ok) { ok = true; if (d.csrf) { setCsrf(d.csrf); window.hyprcrd?.util?.setCsrf?.(d.csrf); } } + else { /* failed login: caller will retry */ } } } diff --git a/public/js/hyprcrd.js b/public/js/hyprcrd.js index c2fbc3f..05e96ea 100644 --- a/public/js/hyprcrd.js +++ b/public/js/hyprcrd.js @@ -98,6 +98,7 @@ }; // ── Util helpers (mirror of internal util.js) ─────────────────── + let csrfToken = ''; const util = { $(idOrSel) { if (typeof idOrSel !== 'string') return null; @@ -114,6 +115,105 @@ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); }, + // CSRF: core calls setCsrf() after auth so plugins can read it + setCsrf(token) { csrfToken = String(token || ''); }, + csrf() { return csrfToken; }, + }; + + /** Generic JSON API call with automatic CSRF header. */ + async function api(path, opts = {}) { + const headers = { 'Content-Type': 'application/json' }; + if (csrfToken) headers['X-CSRF-Token'] = csrfToken; + const res = await fetch(path, { + method: opts.method || 'GET', + headers, + body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, + }); + return res.json(); + } + + // ── Settings registry ─────────────────────────────────────────── + // Plugins can inject their own tabs into the main settings overlay. + // The plugin's onShow callback is called whenever the tab becomes visible. + const settings = { + registerTab({ id, label, icon, onShow }) { + if (!id) return; + const nav = document.getElementById('settings-nav'); + const panelsContainer = document.querySelector('#settings-overlay .overflow-y-auto.p-5'); + if (!nav || !panelsContainer) { + // DOM not ready — defer to next tick + requestAnimationFrame(() => settings.registerTab({ id, label, icon, onShow })); + return; + } + if (nav.querySelector(`[data-tab="${id}"]`)) return; // already registered + + // Create tab button + const btn = document.createElement('button'); + btn.className = 'settings-tab'; + btn.dataset.tab = id; + const iconClass = icon || 'ph-puzzle-piece'; + btn.innerHTML = `${label || id}`; + nav.appendChild(btn); + + // Create empty panel container + let panel = document.getElementById(`settings-panel-${id}`); + if (!panel) { + panel = document.createElement('div'); + panel.id = `settings-panel-${id}`; + panel.className = 'hidden'; + panelsContainer.appendChild(panel); + } + + // Click handler — duplicates core tab logic (theme.js may also + // attach its own handler later, but identical work is harmless). + btn.addEventListener('click', () => { + nav.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); + btn.classList.add('active'); + document.querySelectorAll('[id^="settings-panel-"]').forEach(p => p.classList.add('hidden')); + panel.classList.remove('hidden'); + if (typeof onShow === 'function') { + try { onShow(panel); } catch (err) { console.error(`[settings tab ${id}]`, err); } + } + hooks.emit('settings.tab.shown', { tab: id, panel }); + }); + }, + + /** Open the main settings overlay. */ + open() { + document.getElementById('settings-btn')?.click(); + }, + + /** Open the settings overlay and switch to a specific tab. */ + openTab(id) { + this.open(); + // Settings opens via click; the tab might need a frame to render + requestAnimationFrame(() => { + document.querySelector(`#settings-nav [data-tab="${id}"]`)?.click(); + }); + }, + }; + + // ── Dialog API (filled in by core dialog.js after load) ───────── + // Plugins call hyprcrd.dialog.confirm(...) / hyprcrd.dialog.show(...) + // instead of native browser confirm()/prompt() so the UI stays themed + // and consistent. + const dialog = { + show: null, // (opts) => Promise — set by core dialog.js + /** Convenience confirm wrapper. Returns Promise. */ + async confirm(message, opts = {}) { + if (typeof dialog.show !== 'function') { + // Core dialog not loaded yet — fall back to native + return window.confirm(message) ? true : null; + } + return dialog.show({ + title: opts.title || 'confirm', + message, + input: false, + danger: opts.danger ?? true, + okLabel: opts.okLabel || 'ok', + cancelLabel: opts.cancelLabel || 'cancel', + }); + }, }; // ── Plugin metadata registry ──────────────────────────────────── @@ -128,6 +228,9 @@ editor, markdown, util, + api, + settings, + dialog, plugins, registerPlugin, version: '1.0', diff --git a/public/js/modules/dialog.js b/public/js/modules/dialog.js index 24ff9a6..3fc3f65 100644 --- a/public/js/modules/dialog.js +++ b/public/js/modules/dialog.js @@ -150,6 +150,11 @@ export function showDialog({ title, message = '', input = false, placeholder = ' export function isDialogOpen() { return overlay.classList.contains('open'); } +// Expose to plugins via the hyprcrd API +if (typeof window !== 'undefined' && window.hyprcrd?.dialog) { + window.hyprcrd.dialog.show = showDialog; +} + okBtn.addEventListener('click', submit); cancelBtn.addEventListener('click', () => { if (canCancel()) close(null); }); if (closeBtn) closeBtn.addEventListener('click', () => { if (canCancel()) close(null); }); diff --git a/public/plugins/MINDMAP.md b/public/plugins/MINDMAP.md new file mode 100644 index 0000000..8484ed1 --- /dev/null +++ b/public/plugins/MINDMAP.md @@ -0,0 +1,294 @@ +# hyprcrd plugin system — mindmap + +A complete architectural reference. Don't lose state on this — every detail +here was learned the hard way during the canvas/tests/email plugin builds. + +``` +hyprcrd plugin system +│ +├── Discovery (PHP) +│ ├── src/PluginManager.php (singleton) +│ │ ├── scan(dir) → reads each subdir's plugin.json +│ │ ├── loadDisabled(stateFile) → reads data/plugins.json {disabled: [...]} +│ │ ├── loadBackends() → require_once enabled backend.php files +│ │ └── isEnabled(name) → checked by cssLinks/htmlFragments/scriptTags +│ └── src/bootstrap.php (call order matters!) +│ 1. config.php +│ 2. Database.php +│ 3. PluginManager.php +│ 4. PluginManager::scan(public/plugins) +│ 5. PluginManager::loadDisabled(dirname(DB_PATH).'/plugins.json') +│ 6. PluginManager::loadBackends() ← AFTER loadDisabled, so disabled plugins skip backend.php too +│ +├── plugin.json manifest format +│ ├── name (string, unique) +│ ├── version (string) +│ ├── description (string) +│ ├── frontend +│ │ ├── js: [...] (loaded as +│ ├── Then plugin entries: scriptTags() ?> +│ ├── Then core ES module: