'use strict'; // ── Toast / error helpers ───────────────────────────────────────────────────── function showToast(msg, type = 'secondary') { let box = document.getElementById('toastContainer'); if (!box) { box = document.createElement('div'); box.id = 'toastContainer'; box.style.cssText = 'position:fixed;bottom:1rem;right:1rem;z-index:9999;min-width:260px'; document.body.appendChild(box); } const t = document.createElement('div'); t.className = `toast align-items-center text-bg-${type} border-0 show mb-2`; t.setAttribute('role', 'alert'); t.innerHTML = `
${esc(msg)}
`; box.appendChild(t); bootstrap.Toast.getOrCreateInstance(t, { delay: 4000 }).show(); t.addEventListener('hidden.bs.toast', () => t.remove()); } const showError = msg => showToast(msg, 'danger'); const showSuccess = msg => showToast(msg, 'success'); // ── Confirm modal (no browser dialogs) ─────────────────────────────────────── // The modal is a single shared element. Earlier versions left the previous // confirm-handler attached when the user cancelled, so a second call would // add a *second* listener — clicking Confirm then ran the cancelled call's // callback. This wires both confirm and the modal's hidden event each time // and tears them down regardless of how the modal closes. function confirmAction(msg, cb) { let modal = document.getElementById('_confirmModal'); if (!modal) { modal = document.createElement('div'); modal.className = 'modal fade'; modal.id = '_confirmModal'; modal.innerHTML = ``; document.body.appendChild(modal); } document.getElementById('_confirmMsg').textContent = msg; const bsM = bootstrap.Modal.getOrCreateInstance(modal); const ok = document.getElementById('_confirmOk'); let confirmed = false; const onConfirm = () => { confirmed = true; bsM.hide(); }; const onHidden = () => { ok.removeEventListener('click', onConfirm); modal.removeEventListener('hidden.bs.modal', onHidden); if (confirmed) cb(); }; ok.addEventListener('click', onConfirm); modal.addEventListener('hidden.bs.modal', onHidden); bsM.show(); } // ── Utilities ───────────────────────────────────────────────────────────────── function esc(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } function fmtSize(b) { if (b == null) return ''; if (b < 1024) return b + ' B'; if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB'; return (b / 1024 / 1024).toFixed(1) + ' MB'; } function modeForFile(name) { const ext = (name.split('.').pop() || '').toLowerCase(); return { js:'javascript', ts:'javascript', json:'javascript', css:'css', php:'php', html:'htmlmixed', htm:'htmlmixed', xml:'xml', svg:'xml', md:'markdown', markdown:'markdown', yml:'yaml', yaml:'yaml', sh:'shell', bash:'shell' }[ext] || null; } function isImage(name) { return /\.(jpe?g|png|gif|webp|svg)$/i.test(name); } function isVideo(name) { return /\.(mp4|webm|mov)$/i.test(name); } function isAudio(name) { return /\.(mp3|wav|ogg|flac)$/i.test(name); } // ── Dashboard: add project ──────────────────────────────────────────────────── const addProjectForm = document.getElementById('addProjectForm'); if (addProjectForm) { addProjectForm.addEventListener('submit', async e => { e.preventDefault(); const err = document.getElementById('addProjectError'); err.classList.add('d-none'); const data = Object.fromEntries(new FormData(addProjectForm)); const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data), }); const json = await res.json(); if (json.ok) { location.reload(); } else { err.textContent = json.error || 'Error'; err.classList.remove('d-none'); } }); } // ── Project: delete ─────────────────────────────────────────────────────────── const confirmDelete = document.getElementById('confirmDelete'); if (confirmDelete) { confirmDelete.addEventListener('click', async () => { const id = parseInt(confirmDelete.dataset.projectId); const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id }), }); const json = await res.json(); if (json.ok) location.href = '/'; else showError(json.error || 'Error'); }); } // ── File editor — tabbed pane system ───────────────────────────────────────── // Falls back to modal when no tab bar exists (e.g. posts pane) let _tabCounter = 0; const _tabs = []; // [{id, pid, path, name, kind}] kind: 'editor'|'preview' let _activeTabId = null; const _tabCMs = {}; // id → CodeMirror instance function _isMediaFile(name) { return /\.(jpe?g|png|gif|webp|svg|mp4|webm|mp3|wav|ogg|flac|pdf)$/i.test(name); } function _mediaKind(name) { if (/\.(jpe?g|png|gif|webp|svg)$/i.test(name)) return 'image'; if (/\.(mp4|webm)$/i.test(name)) return 'video'; if (/\.(mp3|wav|ogg|flac)$/i.test(name)) return 'audio'; if (/\.pdf$/i.test(name)) return 'pdf'; return null; } function openFileEditor(pid, path, name) { const tabBar = document.getElementById('editorTabBar'); const tabContent = document.getElementById('editorTabContent'); const browser = document.getElementById('fileBrowser'); const isStorage = browser?.dataset.projectType === 'storage'; // ── Files tab: full tab system ──────────────────────────────────────────── if (tabBar && tabContent) { // Storage images → gallery overlay instead of pane preview if (isStorage && _mediaKind(name) === 'image') { _showGalleryOverlay(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`, name); return; } // Already open? Switch to it. const existing = _tabs.find(t => t.pid === pid && t.path === path); if (existing) { _switchTab(existing.id); return; } // Media preview tab if (_isMediaFile(name)) { const id = 'tab' + (++_tabCounter); _tabs.push({ id, pid, path, name, kind: 'preview' }); _renderTabBar(); const div = document.createElement('div'); div.id = 'tc-' + id; div.style.display = 'none'; div.style.height = '100%'; div.innerHTML = _buildPreviewHTML(pid, path, name); tabContent.appendChild(div); _switchTab(id); return; } // Text editor tab fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) .then(r => r.json()) .then(data => { if (data.error) { showError(data.error); return; } const isMd = /\.(md|markdown)$/i.test(name); const isDraft = path.startsWith('source/_drafts/'); if (isMd) { _openMdEditorTab(pid, path, name, data.content ?? '', isDraft); return; } const id = 'tab' + (++_tabCounter); _tabs.push({ id, pid, path, name, kind: 'editor', isDraft, dirty: false }); _renderTabBar(); const div = document.createElement('div'); div.id = 'tc-' + id; div.style.display = 'none'; div.style.height = '100%'; div.style.position = 'relative'; div.innerHTML = `
`; tabContent.appendChild(div); const cm = CodeMirror(div.querySelector('#paneCm-' + id), { value: data.content ?? '', mode: modeForFile(name), theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, extraKeys: { 'Ctrl-S': () => _paneTabSave(id), 'Cmd-S': () => _paneTabSave(id) }, }); cm.on('change', () => _markDirty(id)); _tabCMs[id] = cm; div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id)); // Ctrl/Cmd+S anywhere in the pane saves. div.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); _paneTabSave(id); } }); _switchTab(id); }) .catch(err => showError('Error: ' + err.message)); return; } // ── Fallback: modal ─────────────────────────────────────────────────────── fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) .then(r => r.json()) .then(data => { if (data.error) { showError(data.error); return; } _renderModalEditor(pid, path, name, data.content ?? ''); }) .catch(err => showError('Error: ' + err.message)); } function _buildPreviewHTML(pid, path, name) { const src = `/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`; const mk = _mediaKind(name); if (mk === 'image') return `
${esc(name)}

${esc(name)}

`; if (mk === 'pdf') return `

${esc(name)}

`; if (mk === 'video') return `

${esc(name)}

`; if (mk === 'audio') return `

${esc(name)}

`; return `

${esc(name)}

`; } function _showGalleryOverlay(src, name) { let ov = document.getElementById('_galleryOverlay'); if (!ov) { ov = document.createElement('div'); ov.id = '_galleryOverlay'; ov.className = 'gallery-overlay'; ov.innerHTML = ``; ov.addEventListener('click', () => ov.remove()); document.body.appendChild(ov); } document.getElementById('_galleryImg').src = src; document.getElementById('_galleryCaption').textContent = name; if (!document.body.contains(ov)) document.body.appendChild(ov); } function _renderTabBar() { const bar = document.getElementById('editorTabBar'); if (!bar) { _saveTabState(); return; } if (!_tabs.length) { bar.innerHTML = ''; _saveTabState(); return; } bar.innerHTML = _tabs.map(t => { const cmDirty = t.kind === 'editor' && _tabCMs[t.id]?.isClean() === false; const dirty = (t.dirty || cmDirty) ? '' : ''; const icon = t.kind === 'preview' ? '' : ''; return `
${icon}${esc(t.name)}${dirty}
`; }).join(''); bar.querySelectorAll('.editor-tab').forEach(el => { el.addEventListener('click', ev => { if (ev.target.closest('[data-close-tab]')) return; _switchTab(el.dataset.tabId); }); }); bar.querySelectorAll('[data-close-tab]').forEach(el => { el.addEventListener('click', () => _closeTab(el.dataset.closeTab)); }); _saveTabState(); } function _tabStorageKey() { const fb = document.getElementById('fileBrowser'); const pp = document.getElementById('postsPanel'); const el = fb || pp; if (!el) return null; return 'hackmancms_tabs_' + el.dataset.projectId + '_' + (fb ? 'files' : 'posts'); } function _saveTabState() { const key = _tabStorageKey(); if (!key) return; const items = _tabs .filter(t => t.path && (t.kind === 'editor' || t.kind === 'preview')) .map(t => ({ pid: t.pid, path: t.path, name: t.name, kind: t.kind })); if (!items.length) localStorage.removeItem(key); else localStorage.setItem(key, JSON.stringify({ tabs: items, active: _activeTabId })); } function _restoreTabState() { const key = _tabStorageKey(); if (!key) return; let saved; try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { saved = null; } if (!saved?.tabs?.length) return; saved.tabs.forEach(t => openFileEditor(t.pid, t.path, t.name)); } function _switchTab(id) { _tabs.forEach(t => { const div = document.getElementById('tc-' + t.id); if (div) div.style.display = 'none'; }); const div = document.getElementById('tc-' + id); if (div) { div.style.display = 'flex'; div.style.flexDirection = 'column'; div.style.height = '100%'; requestAnimationFrame(() => _tabCMs[id]?.refresh()); } _activeTabId = id; _renderTabBar(); // Hide placeholder const content = document.getElementById('editorTabContent'); const placeholder = content?.querySelector('.editor-placeholder'); if (placeholder) placeholder.style.display = 'none'; } function _closeTab(id) { const idx = _tabs.findIndex(t => t.id === id); if (idx === -1) return; if (_tabCMs[id]) { try { _tabCMs[id].toTextArea(); } catch(e) {} delete _tabCMs[id]; } delete _tabMdMounts[id]; if (_tabMdReflowers[id]) { try { _tabMdReflowers[id].disconnect(); } catch (e) {} delete _tabMdReflowers[id]; } if (_tabImgObservers[id]) { try { _tabImgObservers[id].disconnect(); } catch (e) {} delete _tabImgObservers[id]; } document.getElementById('tc-' + id)?.remove(); _tabs.splice(idx, 1); if (_activeTabId === id) { const next = _tabs[Math.min(idx, _tabs.length - 1)]; if (next) { _switchTab(next.id); } else { _activeTabId = null; const content = document.getElementById('editorTabContent'); if (content) { const ph = content.querySelector('.editor-placeholder'); if (ph) ph.style.display = ''; else content.innerHTML = `
Select a file to edit
`; } } } _renderTabBar(); } function _markDirty(id) { const tab = _tabs.find(t => t.id === id); if (tab) tab.dirty = true; _renderTabBar(); _updateSaveBtnState(id); } // ── Markdown editor (Milkdown via Agenda-style mk-mount) ──────────────────── // Each .md tab gets a textarea.mk-mount whose value is the body markdown // (image URLs pre-rewritten to served URLs); milkdown-mount.js wraps it in // a WYSIWYG editor with a built-in MD/WYSIWYG toolbar toggle. const _tabMdMounts = {}; // id → textarea handle (.mkMount) const _tabFmCMs = {}; // id → CodeMirror (front matter YAML) const _tabMdReflowers = {}; // id → ResizeObserver // Walk the rendered ProseMirror DOM and rewrite each 's `src` to the // served URL — without touching the editor's underlying model. The model // keeps the authored path (e.g. `images/foo.png`), so getMarkdown() returns // clean markdown on save and Hexo sees exactly what the user wrote. const _tabImgObservers = {}; function _rewriteImgsInPlace(root, pid) { if (!root) return; root.querySelectorAll('img').forEach(img => { const cur = img.getAttribute('src') || ''; if (!cur) return; if (cur.includes('/api/files')) return; // already rewritten if (/^(https?:|data:|\/\/)/i.test(cur)) return; // absolute, leave as-is const resolved = _resolveImagePathForEditor(cur, pid); if (resolved !== cur) { img.setAttribute('data-original-src', cur); img.setAttribute('src', resolved); } }); } function _attachImgSrcRewriter(id, pid) { const root = document.getElementById('tc-' + id); const pm = root?.querySelector('.ProseMirror'); if (!pm) return; // Initial pass for whatever's already rendered _rewriteImgsInPlace(pm, pid); // Watch for newly added or changed elements (paste, image insert, // ProseMirror re-render). Filtering attributeFilter to `src` avoids loops // when our own setAttribute fires a notification. const obs = new MutationObserver(() => _rewriteImgsInPlace(pm, pid)); obs.observe(pm, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); // Replace any existing observer (e.g. on re-mount) if (_tabImgObservers[id]) try { _tabImgObservers[id].disconnect(); } catch (e) {} _tabImgObservers[id] = obs; } function _reflowMdEditor(id) { const mount = document.getElementById('paneEditor-' + id); if (!mount) return; const stackH = mount.clientHeight; if (stackH < 1) return; // Editor mount may not be present yet (mk-mount wraps the textarea async). const wrap = mount.querySelector('.mk-mount-wrap'); if (!wrap) return; const body = wrap.querySelector('.ie-mk-body'); if (!body) return; const wrapToolbar = wrap.querySelector('.ie-mk-toolbar'); const innerPhotos = body.querySelector('.md-photos-banner'); const toolbarH = wrapToolbar ? wrapToolbar.offsetHeight : 40; const photosH = innerPhotos && !innerPhotos.classList.contains('d-none') ? innerPhotos.offsetHeight : 0; const wrapBd = 2; // Lock wrap and body to definite pixel heights — ProseMirror's percentage // min-height doesn't cascade reliably through Milkdown's wrappers, so we // size them manually to make body's overflow-y: auto fire dependably. wrap.style.flex = '0 0 auto'; wrap.style.height = stackH + 'px'; const bodyH = Math.max(80, stackH - toolbarH - wrapBd); body.style.flex = '0 0 auto'; body.style.height = bodyH + 'px'; const editorMinH = Math.max(60, bodyH - photosH); const milkdownRoot = body.querySelector(':scope > div:not(.md-photos-banner)'); if (milkdownRoot) milkdownRoot.style.minHeight = editorMinH + 'px'; body.querySelectorAll('.milkdown, .editor, .ProseMirror').forEach(el => { el.style.minHeight = editorMinH + 'px'; }); const taSrc = body.querySelector('textarea.ie-mk-ta'); if (taSrc) { taSrc.style.minHeight = editorMinH + 'px'; taSrc.style.height = editorMinH + 'px'; } } async function _openMdEditorTab(pid, path, name, content, isDraft) { const tabContent = document.getElementById('editorTabContent'); if (!tabContent) return; const id = 'tab' + (++_tabCounter); const { fm, body, photos } = _parseMdSource(content); const hadFm = content.startsWith('---'); const isHexo = path.startsWith('source/_posts/') || path.startsWith('source/_drafts/'); const useFmBlock = hadFm || isHexo; _tabs.push({ id, pid, path, name, kind: 'md-editor', isDraft, dirty: false }); _renderTabBar(); const div = document.createElement('div'); div.id = 'tc-' + id; div.style.display = 'none'; div.style.height = '100%'; div.style.position = 'relative'; div.innerHTML = `
`; tabContent.appendChild(div); const mountEl = div.querySelector('#paneEditor-' + id); const ta = document.createElement('textarea'); ta.className = 'mk-mount'; // FM lives as the first fenced yaml code block inside Milkdown. // On save the block is extracted and serialised back to ---\nfm\n--- form. ta.value = useFmBlock ? '```yaml\n' + fm + '\n```\n\n' + body : body; ta.dataset.hasFmBlock = useFmBlock ? '1' : '0'; mountEl.appendChild(ta); ta.onMkInput = () => _markDirty(id); _tabMdMounts[id] = ta; const ro = new ResizeObserver(() => _reflowMdEditor(id)); ro.observe(mountEl); _tabMdReflowers[id] = ro; ta.addEventListener('mk-mounted', () => { _injectMkToolbarExtras(id, pid, path, isDraft); _reflowMdEditor(id); }); ta.addEventListener('mk-ready', () => { const editorBody = div.querySelector('.ie-mk-body'); if (editorBody) { let banner = editorBody.querySelector('.md-photos-banner'); if (!banner) { banner = document.createElement('div'); banner.className = 'md-photos-banner inside-editor'; banner.id = 'panePhotos-' + id; editorBody.insertBefore(banner, editorBody.firstChild); } _renderPhotosBanner(banner, photos, pid); } _reflowMdEditor(id); _attachImgSrcRewriter(id, pid); }); div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id)); div.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); _paneTabSave(id); } }); _switchTab(id); requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id))); } function _injectMkToolbarExtras(id, pid, path, isDraft) { const root = document.getElementById('tc-' + id); if (!root) return; const tb = root.querySelector('.ie-mk-toolbar'); if (!tb || tb.querySelector('.mk-pane-kebab')) return; const publishItem = isDraft ? `
  • ` : ''; const wrap = document.createElement('div'); wrap.className = 'dropdown mk-pane-kebab ms-1'; wrap.innerHTML = ` `; tb.appendChild(wrap); wrap.querySelector('#paneDeleteBtn-' + id).addEventListener('click', () => _paneTabDelete(id)); wrap.querySelector('#paneRenameBtn-' + id).addEventListener('click', () => { promptRename(pid, path, newPath => { const tab = _tabs.find(t => t.id === id); if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); } if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post'); }); }); wrap.querySelector('#paneMoveBtn-' + id).addEventListener('click', () => { promptMove(pid, path, newPath => { const tab = _tabs.find(t => t.id === id); if (tab) { tab.path = newPath; _renderTabBar(); } if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post'); }); }); if (isDraft) { wrap.querySelector('#panePublishBtn-' + id)?.addEventListener('click', () => _paneTabPublish(id, pid, path)); } } function _updateSaveBtnState(id) { const tab = _tabs.find(t => t.id === id); const btn = document.getElementById('paneSaveBtn-' + id); if (!tab || !btn) return; btn.classList.toggle('d-none', !tab.dirty); } // Parse a markdown source into { fm: yamlString, body: string, photos: string[] } function _parseMdSource(text) { const out = { fm: '', body: text, photos: [] }; if (!text.startsWith('---')) return out; const end = text.indexOf('\n---', 3); if (end === -1) return out; out.fm = text.substring(3, end).replace(/^\n/, '').replace(/\n$/, ''); out.body = text.substring(end + 4).replace(/^\s*\n/, ''); // photos: inline array → photos: [a, b, c] const inline = out.fm.match(/^photos:\s*\[(.+?)\]\s*$/m); if (inline) { out.photos = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, '')) .filter(Boolean); } else { // photos: block list → photos:\n - a\n - b const block = out.fm.match(/^photos:\s*\n((?:[ \t]*-\s*.+\n?)+)/m); if (block) { out.photos = [...block[1].matchAll(/^[ \t]*-\s*(.+?)\s*$/gm)] .map(m => m[1].trim().replace(/^["']|["']$/g, '')) .filter(Boolean); } else { // photos: single value on the same line → photos: foo.jpg const single = out.fm.match(/^photos:\s*([^\s\[].*?)\s*$/m); if (single) out.photos = [single[1].replace(/^["']|["']$/g, '')]; } } return out; } function _renderPhotosBanner(el, photos, pid) { if (!el) return; if (!photos.length) { el.classList.add('d-none'); el.innerHTML = ''; return; } el.classList.remove('d-none'); el.innerHTML = photos.map(p => { const url = _resolveImagePathForEditor(p, pid); return ``; }).join(''); } // images/foo.png → /api/files?project_id=X&action=serve&path=source/images/foo.png // Already-absolute or already-rewritten URLs pass through unchanged. function _resolveImagePathForEditor(src, pid) { if (!src) return src; if (/^(https?:|data:|\/\/)/i.test(src)) return src; if (src.includes('/api/files')) return src; let rel = src.replace(/^\.?\//, ''); if (!rel.startsWith('source/')) rel = 'source/' + rel; return `/api/files?project_id=${pid}&action=serve&path=${encodeURIComponent(rel)}`; } // Rewrite ![]() image URLs for in-editor display. function _rewriteImagePathsForEditor(markdown, pid) { return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => pre + _resolveImagePathForEditor(src, pid) + post); } // Reverse: strip the /api/files prefix back to the original relative form. function _reverseRewriteImagePaths(markdown, pid) { const prefix = `/api/files?project_id=${pid}&action=serve&path=`; return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => { if (src.startsWith(prefix)) { let p = decodeURIComponent(src.substring(prefix.length)); if (p.startsWith('source/')) p = p.substring(7); return pre + p + post; } return m; }); } async function _paneTabSave(id) { const tab = _tabs.find(t => t.id === id); if (!tab) return; const status = document.getElementById('paneStatus-' + id); let content; if (tab.kind === 'md-editor') { const mount = _tabMdMounts[id]; if (!mount?.mkMount) { showError('Editor not ready'); return; } const md = mount.mkMount.getContent() ?? ''; // FM lives as the first ```yaml block; extract it and convert to ---\n...\n--- const fmMatch = md.match(/^```yaml\r?\n([\s\S]*?)\n?```(\r?\n|$)/); if (fmMatch) { const fm = fmMatch[1]; const body = md.substring(fmMatch[0].length).replace(/^\s*\n/, ''); content = '---\n' + fm + '\n---\n\n' + body; } else { content = md; } } else { if (!_tabCMs[id]) return; content = _tabCMs[id].getValue(); } if (status) status.textContent = 'Saving…'; const res = await fetch('/api/files?project_id=' + tab.pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'write', path: tab.path, content }), }); const data = await res.json(); if (data.ok) { tab.dirty = false; if (tab.kind !== 'md-editor') _tabCMs[id]?.markClean(); if (status) status.textContent = 'Saved.'; showSuccess('Saved'); _renderTabBar(); _updateSaveBtnState(id); } else { if (status) status.textContent = ''; showError(data.error || 'Save failed'); } } async function _paneTabDelete(id) { const tab = _tabs.find(t => t.id === id); if (!tab) return; confirmAction(`Delete "${tab.name}"? This cannot be undone.`, async () => { const res = await fetch('/api/files?project_id=' + tab.pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', path: tab.path }), }); const data = await res.json(); if (data.ok) { showSuccess('Deleted ' + tab.name); _closeTab(id); const browser = document.getElementById('fileBrowser'); if (browser) { const currentPath = browser.dataset.currentPath || ''; if (typeof loadDir === 'function') loadDir(currentPath); } if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post'); } else { showError(data.error || 'Delete failed'); } }); } async function _paneTabPublish(id, pid, path) { const tab = _tabs.find(t => t.id === id); if (!tab) return; confirmAction('Publish draft to _posts? This moves the file.', async () => { const relpath = tab.path.replace('source/_drafts/', ''); const res = await fetch('/api/posts?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'publish', relpath }), }); const data = await res.json(); if (data.ok) { showSuccess('Published!'); _closeTab(id); _postsLoadFn('post'); } else { showError(data.error || 'Publish failed'); } }); } function promptRename(pid, path, onSuccess) { const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('renameModal')); const input = document.getElementById('renameInput'); const err = document.getElementById('renameError'); input.value = path.split('/').pop(); err.classList.add('d-none'); const confirm = async () => { const newName = input.value.trim(); if (!newName) { err.textContent = 'Name required'; err.classList.remove('d-none'); return; } const res = await fetch('/api/files?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'rename', path, newName }), }); const data = await res.json(); if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; } modal.hide(); showSuccess('Renamed'); onSuccess(data.newPath); }; document.getElementById('renameConfirmBtn').onclick = confirm; input.onkeydown = e => { if (e.key === 'Enter') confirm(); }; modal.show(); setTimeout(() => { input.select(); }, 300); } function promptMove(pid, path, onSuccess) { const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('moveModal')); const input = document.getElementById('moveInput'); const err = document.getElementById('moveError'); const parts = path.split('/'); parts.pop(); input.value = parts.join('/') + '/'; err.classList.add('d-none'); const confirm = async () => { const dir = input.value.trim().replace(/\/$/, ''); const file = path.split('/').pop(); const newPath = dir + '/' + file; const res = await fetch('/api/files?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'move', path, newPath }), }); const data = await res.json(); if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; } modal.hide(); showSuccess('Moved'); onSuccess(data.newPath); }; document.getElementById('moveConfirmBtn').onclick = confirm; input.onkeydown = e => { if (e.key === 'Enter') confirm(); }; modal.show(); setTimeout(() => { const l = input.value.length; input.setSelectionRange(l, l); input.focus(); }, 300); } // Modal editor (used from posts pane where there's no tab bar) let _modalCM = null; function _renderModalEditor(pid, path, name, content) { document.getElementById('fileEditName').textContent = name; document.getElementById('fileEditPath').value = path; document.getElementById('fileEditPid').value = pid; document.getElementById('fileEditStatus').textContent = ''; const container = document.getElementById('fileEditCm'); container.innerHTML = ''; if (_modalCM) { try { _modalCM.toTextArea(); } catch(e) {} _modalCM = null; } _modalCM = CodeMirror(container, { value: content, mode: modeForFile(name), theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, extraKeys: { 'Ctrl-S': saveFile, 'Cmd-S': saveFile }, }); const modal = document.getElementById('fileEditModal'); bootstrap.Modal.getOrCreateInstance(modal).show(); modal.addEventListener('shown.bs.modal', () => _modalCM?.refresh(), { once: true }); } async function saveFile() { const pid = document.getElementById('fileEditPid').value; const path = document.getElementById('fileEditPath').value; const content = _modalCM ? _modalCM.getValue() : ''; const status = document.getElementById('fileEditStatus'); status.textContent = 'Saving…'; const res = await fetch('/api/files?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'write', path, content }), }); const data = await res.json(); if (data.ok) { status.textContent = 'Saved.'; showSuccess('Saved'); } else { status.textContent = ''; showError(data.error || 'Save failed'); } } document.getElementById('fileEditSave')?.addEventListener('click', saveFile); // ── File browser ────────────────────────────────────────────────────────────── const fileBrowser = document.getElementById('fileBrowser'); if (fileBrowser) { const pid = fileBrowser.dataset.projectId; let fileBrowserCurrentPath = ''; async function loadDir(relPath) { fileBrowserCurrentPath = relPath; fileBrowser.dataset.currentPath = relPath; const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`); const data = await res.json(); if (data.error) { renderBrowserError(data.error); return; } renderCrumb(relPath, '#fileCrumb', loadDir); renderEntries(data.entries); } function renderEntries(entries) { const list = document.getElementById('fileList'); if (!entries.length) { list.innerHTML = '
    Empty directory
    '; return; } list.innerHTML = entries.map(e => { const icon = e.type === 'dir' ? 'bi-folder-fill text-warning' : 'bi-file-text text-secondary'; const kebab = e.type === 'file' ? ` ` : ''; return `
    ${esc(e.name)} ${e.size != null ? `${fmtSize(e.size)}` : ''} ${kebab}
    `; }).join(''); list.querySelectorAll('[data-type]').forEach(row => { row.addEventListener('click', ev => { if (ev.target.closest('button')) return; row.dataset.type === 'dir' ? loadDir(row.dataset.path) : openFileEditor(pid, row.dataset.path, row.dataset.name); }); }); list.querySelectorAll('.btn-open-file').forEach(btn => { btn.addEventListener('click', () => openFileEditor(pid, btn.dataset.path, btn.dataset.name)); }); list.querySelectorAll('.btn-del-file').forEach(btn => { btn.addEventListener('click', () => { confirmAction(`Delete "${btn.dataset.name}"?`, async () => { const r = await fetch('/api/files?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', path: btn.dataset.path }), }); const d = await r.json(); if (d.ok) { showSuccess('Deleted ' + btn.dataset.name); // Close the editor tab if this file was open const openTab = _tabs.find(t => t.path === btn.dataset.path); if (openTab) _closeTab(openTab.id); loadDir(fileBrowserCurrentPath); } else showError(d.error || 'Delete failed'); }); }); }); } function renderBrowserError(msg) { document.getElementById('fileList').innerHTML = `
    ${esc(msg)}
    `; } // Drag-and-drop upload — whole left pane as drop zone const dropZone = fileBrowser.querySelector('.split-list') || fileBrowser; let dragDepth = 0; dropZone.addEventListener('dragenter', e => { e.preventDefault(); dragDepth++; dropZone.classList.add('drag-over'); }); dropZone.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; dropZone.classList.remove('drag-over'); } }); dropZone.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); dropZone.addEventListener('drop', async e => { e.preventDefault(); dragDepth = 0; dropZone.classList.remove('drag-over'); const files = [...(e.dataTransfer.files || [])]; if (!files.length) return; let ok = 0; for (const file of files) { const fd = new FormData(); fd.append('project_id', pid); fd.append('folder', fileBrowserCurrentPath); fd.append('accept', 'any'); fd.append('file', file); try { const res = await fetch('/api/upload', { method: 'POST', body: fd }); const data = await res.json(); data.ok ? ok++ : showError(`${file.name}: ${data.error || 'Upload failed'}`); } catch(err) { showError(`${file.name}: ${err.message}`); } } if (ok > 0) { showSuccess(`Uploaded ${ok} file${ok > 1 ? 's' : ''}`); loadDir(fileBrowserCurrentPath); } }); const initialPath = new URLSearchParams(location.search).get('path') || ''; loadDir(initialPath); } // ── Command runner ──────────────────────────────────────────────────────────── const commandRunner = document.getElementById('commandRunner'); if (commandRunner) { const pid = commandRunner.dataset.projectId; const output = document.getElementById('cmdOutput'); document.getElementById('clearOutput')?.addEventListener('click', () => { output.textContent = ''; }); commandRunner.querySelectorAll('.btn-run-cmd').forEach(btn => { btn.addEventListener('click', async () => { const cmd = btn.dataset.cmd; output.textContent += `\n$ ${cmd}\n`; btn.disabled = true; const res = await fetch('/api/run', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ project_id: parseInt(pid), cmd }), }); const data = await res.json(); output.textContent += data.error ? `ERROR: ${data.error}\n` : (data.output || '(no output)') + `\nExit: ${data.exit_code}\n`; output.scrollTop = output.scrollHeight; btn.disabled = false; }); }); } // ── Post tree helpers ───────────────────────────────────────────────────────── let _postFolderCounter = 0; function buildPostTree(items) { const root = { posts: [], children: {} }; for (const item of items) { const segs = item.folder ? item.folder.split('/').filter(Boolean) : []; let node = root; for (const seg of segs) { if (!node.children[seg]) node.children[seg] = { posts: [], children: {} }; node = node.children[seg]; } node.posts.push(item); } return root; } function countTreePosts(node) { let c = node.posts.length; for (const child of Object.values(node.children)) c += countTreePosts(child); return c; } function renderPostItem(p, pid, type) { const rel = esc(p.relpath); const path = esc(p.path); const publishItem = type === 'draft' ? `
  • ` : ''; return `
    ${esc(p.title)}
    ${p.date ? esc(p.date.substring(0,10)) : '—'} • ${esc(p.filename)}
    `; } function renderPostTree(node, pid, type, expandPath = []) { let html = ''; if (node.posts.length) { html += '
    '; for (const p of node.posts) html += renderPostItem(p, pid, type); html += '
    '; } for (const [name, child] of Object.entries(node.children)) { const id = 'pfg' + (++_postFolderCounter); const count = countTreePosts(child); const isOpen = expandPath.length > 0 && expandPath[0] === name; const sub = isOpen ? expandPath.slice(1) : []; html += `
    ${esc(name)} ${count}
    ${renderPostTree(child, pid, type, sub)}
    `; } return html; } function _wirePostList(list, pid, currentTypeRef) { list.querySelectorAll('.post-item-row').forEach(row => { row.addEventListener('click', ev => { if (ev.target.closest('button')) return; openFileEditor(pid, row.dataset.path, row.dataset.name); }); }); list.querySelectorAll('.btn-del-post').forEach(btn => { btn.addEventListener('click', () => confirmAction(`Delete "${btn.dataset.relpath}"?`, async () => { const res = await fetch('/api/posts?project_id=' + pid, { method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ relpath: btn.dataset.relpath, type: btn.dataset.type }), }); const d = await res.json(); d.ok ? _postsLoadFn(currentTypeRef.type) : showError(d.error || 'Error'); })); }); list.querySelectorAll('.btn-dup-post').forEach(btn => { btn.addEventListener('click', async () => { const res = await fetch('/api/posts?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'duplicate', relpath: btn.dataset.relpath, type: btn.dataset.type }), }); const d = await res.json(); d.ok ? (showSuccess('Duplicated as ' + d.relpath), _postsLoadFn(currentTypeRef.type)) : showError(d.error || 'Error'); }); }); list.querySelectorAll('.btn-pub-post').forEach(btn => { btn.addEventListener('click', () => confirmAction('Publish draft to _posts?', async () => { const res = await fetch('/api/posts?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'publish', relpath: btn.dataset.relpath }), }); const d = await res.json(); if (d.ok) { showSuccess('Published!'); const tab = _tabs.find(t => t.path === btn.dataset.path); if (tab) _closeTab(tab.id); _postsLoadFn('post'); } else { showError(d.error || 'Error'); } })); }); list.querySelectorAll('.btn-rename-post').forEach(btn => { btn.addEventListener('click', () => { promptRename(pid, btn.dataset.path, newPath => { const tab = _tabs.find(t => t.path === btn.dataset.path); if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); } _postsLoadFn(currentTypeRef.type); }); }); }); list.querySelectorAll('.btn-move-post').forEach(btn => { btn.addEventListener('click', () => { promptMove(pid, btn.dataset.path, newPath => { const tab = _tabs.find(t => t.path === btn.dataset.path); if (tab) { tab.path = newPath; _renderTabBar(); } _postsLoadFn(currentTypeRef.type); }); }); }); } // Shared reference so modal "Create" button can reload the list let _postsLoadFn = () => {}; // ── Posts panel (Hexo) ─────────────────────────────────────────────────────── const postsPanel = document.getElementById('postsPanel'); if (postsPanel) { const pid = postsPanel.dataset.projectId; const postsList = document.getElementById('postsList'); const searchResults= document.getElementById('searchResults'); let currentType = 'post'; _postsLoadFn = type => loadPostsByType(type); function setActiveBtn(btnId) { ['showPosts','showPages'].forEach(id => { const el = document.getElementById(id); if (!el) return; el.classList.toggle('btn-outline-primary', id === btnId); el.classList.toggle('active', id === btnId); el.classList.toggle('btn-outline-secondary', id !== btnId); }); } // ── Filter pill (tag/category from Dashboard) ─────────────────────────────── let _activeFilter = null; // {kind, value} function _renderFilterBar() { let bar = document.getElementById('postsFilterBar'); if (!bar) { bar = document.createElement('div'); bar.id = 'postsFilterBar'; bar.className = 'mb-2'; postsList.parentElement.insertBefore(bar, postsList); } if (!_activeFilter) { bar.innerHTML = ''; return; } bar.innerHTML = ` ${esc(_activeFilter.kind)}: ${esc(_activeFilter.value)} `; document.getElementById('clearFilterBtn').addEventListener('click', () => { _activeFilter = null; const url = new URL(location); url.searchParams.delete('filter'); history.replaceState({}, '', url); loadPostsByType(currentType); }); } function _applyFilter(items) { if (!_activeFilter) return items; const { kind, value } = _activeFilter; return items.filter(it => { const list = kind === 'tag' ? (it.tags || []) : (it.categories || []); return list.some(v => String(v).toLowerCase() === value.toLowerCase()); }); } async function loadPostsByType(type) { // Drafts are merged into the posts view — always normalise to 'post' if (type === 'draft') type = 'post'; currentType = type; window._postsCurrentType = type; setActiveBtn(type === 'page' ? 'showPages' : 'showPosts'); const sq = document.getElementById('searchQuery'); if (sq) sq.value = ''; searchResults.classList.add('d-none'); postsList.classList.remove('d-none'); postsList.innerHTML = '
    Loading…
    '; if (type === 'post') { // Fetch posts + drafts together const [pr, dr] = await Promise.all([ fetch(`/api/posts?project_id=${pid}&type=post`), fetch(`/api/posts?project_id=${pid}&type=draft`), ]); const [postsData, draftsData] = await Promise.all([pr.json(), dr.json()]); const posts = _applyFilter(postsData.items || []); const drafts = _applyFilter(draftsData.items || []); if (!posts.length && !drafts.length) { postsList.innerHTML = `
    ${_activeFilter ? 'No posts match this filter.' : 'No posts yet.'}
    `; _renderFilterBar(); return; } let html = ''; if (drafts.length) { _postFolderCounter = 0; const draftTree = renderPostTree(buildPostTree(drafts), pid, 'draft'); html += `
    ${draftTree}
    `; } if (posts.length) { const expandPath = posts[0]?.folder ? posts[0].folder.split('/').filter(Boolean) : []; _postFolderCounter = 0; html += `
    ${renderPostTree(buildPostTree(posts), pid, 'post', expandPath)}
    `; } postsList.innerHTML = html; _wirePostList(postsList, pid, { type: 'post' }); _renderFilterBar(); } else { // Pages const res = await fetch(`/api/posts?project_id=${pid}&type=${type}`); const data = await res.json(); if (data.missing_dir) { postsList.innerHTML = `
    source/ not found.
    `; _renderFilterBar(); return; } const items = _applyFilter(data.items || []); if (!items.length) { postsList.innerHTML = `
    ${_activeFilter ? 'No pages match this filter.' : 'No pages yet.'}
    `; _renderFilterBar(); return; } const expandPath = items[0]?.folder ? items[0].folder.split('/').filter(Boolean) : []; _postFolderCounter = 0; postsList.innerHTML = `
    ${renderPostTree(buildPostTree(items), pid, type, expandPath)}
    `; _wirePostList(postsList, pid, { type }); _renderFilterBar(); } } // Read ?filter=tag:foo or ?filter=category:bar from URL const urlFilter = new URLSearchParams(location.search).get('filter'); if (urlFilter && urlFilter.includes(':')) { const [k, ...rest] = urlFilter.split(':'); if (k === 'tag' || k === 'category') { _activeFilter = { kind: k, value: rest.join(':') }; } } document.getElementById('showPosts')?.addEventListener('click', () => loadPostsByType('post')); document.getElementById('showPages')?.addEventListener('click', () => loadPostsByType('page')); let _newTypeTarget = 'post'; function openNewPostModal(type) { _newTypeTarget = type; const labels = { post: 'New Post', draft: 'New Draft', page: 'New Page' }; document.getElementById('newPostModalTitle').textContent = labels[type] ?? 'New'; document.getElementById('newPostTitle').value = ''; document.getElementById('newPostFolder').value = ''; document.getElementById('newPostError').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).show(); setTimeout(() => document.getElementById('newPostTitle').focus(), 300); } document.getElementById('newItemBtn')?.addEventListener('click', () => { openNewPostModal(currentType === 'page' ? 'page' : 'post'); }); postsPanel.querySelectorAll('.new-type-btn').forEach(btn => { btn.addEventListener('click', () => openNewPostModal(btn.dataset.type)); }); document.getElementById('newPostCreateBtn')?.addEventListener('click', async () => { const title = document.getElementById('newPostTitle').value.trim(); const folder = document.getElementById('newPostFolder').value.trim(); const errEl = document.getElementById('newPostError'); if (!title) { errEl.textContent = 'Title required'; errEl.classList.remove('d-none'); return; } const date = new Date().toISOString().replace('T', ' ').substring(0, 19); const fm = `---\ntitle: "${title.replace(/"/g, '\\"')}"\ndate: ${date}\ntags: []\n---`; const res = await fetch('/api/posts?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ type: _newTypeTarget, title, folder, frontmatter: fm }), }); const data = await res.json(); if (!data.ok) { errEl.textContent = data.error || 'Error'; errEl.classList.remove('d-none'); return; } bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).hide(); await loadPostsByType('post'); openFileEditor(pid, data.path, data.filename); }); document.getElementById('newPostTitle')?.addEventListener('keydown', e => { if (e.key === 'Enter') document.getElementById('newPostCreateBtn')?.click(); }); let _searchTimer = null; document.getElementById('searchQuery')?.addEventListener('input', e => { clearTimeout(_searchTimer); const q = e.target.value.trim(); if (!q) { searchResults.classList.add('d-none'); postsList.classList.remove('d-none'); return; } postsList.classList.add('d-none'); searchResults.classList.remove('d-none'); searchResults.innerHTML = '
    Searching…
    '; _searchTimer = setTimeout(async () => { const res = await fetch(`/api/search?project_id=${pid}&q=${encodeURIComponent(q)}`); const data = await res.json(); if (data.error) { searchResults.innerHTML = `
    ${esc(data.error)}
    `; return; } if (!data.results.length) { searchResults.innerHTML = '
    No results.
    '; return; } searchResults.innerHTML = data.results.map(r => `
    ${esc(r.file)} L${r.line}
    ${esc(r.content)}
    `).join(''); searchResults.querySelectorAll('.search-result').forEach(row => { row.addEventListener('click', () => openFileEditor(pid, row.dataset.path, row.dataset.name)); }); }, 350); }); document.getElementById('searchClearBtn')?.addEventListener('click', () => { const sq = document.getElementById('searchQuery'); if (sq) sq.value = ''; searchResults.classList.add('d-none'); postsList.classList.remove('d-none'); }); loadPostsByType('post'); } // ── Upload modal ────────────────────────────────────────────────────────────── // Pre-fill folder from file browser when modal opens document.getElementById('uploadModal')?.addEventListener('show.bs.modal', () => { const browser = document.getElementById('fileBrowser'); const folder = document.getElementById('uploadFolder'); if (browser && folder) folder.value = browser.dataset.currentPath || ''; document.getElementById('uploadResult')?.classList.add('d-none'); const uf = document.getElementById('uploadFile'); if (uf) uf.value = ''; }); const uploadSubmit = document.getElementById('uploadSubmit'); if (uploadSubmit) { uploadSubmit.addEventListener('click', async () => { const pid = document.getElementById('uploadPid').value; const accept = document.getElementById('uploadAccept').value; const folder = document.getElementById('uploadFolder').value.trim(); const file = document.getElementById('uploadFile').files[0]; if (!file) { showError('No file selected'); return; } const fd = new FormData(); fd.append('project_id', pid); fd.append('folder', folder); fd.append('accept', accept); fd.append('file', file); uploadSubmit.disabled = true; const res = await fetch('/api/upload', { method: 'POST', body: fd }); const data = await res.json(); uploadSubmit.disabled = false; if (!data.ok) { showError(data.error || 'Upload failed'); return; } const resultDiv = document.getElementById('uploadResult'); const urlInput = document.getElementById('uploadResultUrl'); resultDiv.classList.remove('d-none'); document.getElementById('uploadResultMsg').textContent = `Uploaded: ${data.filename}`; urlInput.value = data.url || data.path; document.getElementById('uploadCopy')?.addEventListener('click', () => { navigator.clipboard.writeText(urlInput.value).then(() => showSuccess('Copied!')); }, { once: true }); }); } // ── Media grid (Storage) ────────────────────────────────────────────────────── const mediaPanel = document.getElementById('mediaPanel'); if (mediaPanel) { const pid = mediaPanel.dataset.projectId; const projectUrl = mediaPanel.dataset.projectUrl; async function loadMedia(relPath) { const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`); const data = await res.json(); if (data.error) { document.getElementById('mediaGrid').innerHTML = `
    ${esc(data.error)}
    `; return; } renderCrumb(relPath, '#mediaCrumb', loadMedia); renderMediaGrid(data.entries, relPath); } function renderMediaGrid(entries, relPath) { const grid = document.getElementById('mediaGrid'); if (!entries.length) { grid.innerHTML = '
    Empty directory
    '; return; } grid.innerHTML = entries.map(e => { if (e.type === 'dir') { return `
    `; } const thumb = isImage(e.name) ? `` : ``; const fileUrl = projectUrl ? rtrim(projectUrl, '/') + '/' + e.path : e.path; return `
    ${thumb}
    `; }).join(''); grid.querySelectorAll('[data-nav-path]').forEach(el => { el.addEventListener('click', () => loadMedia(el.dataset.navPath)); }); grid.querySelectorAll('.btn-copy-url').forEach(btn => { btn.addEventListener('click', () => { navigator.clipboard.writeText(btn.dataset.url).then(() => showSuccess('Copied!')); }); }); } loadMedia(''); } function rtrim(s, c) { return s.endsWith(c) ? s.slice(0, -c.length) : s; } // ── Shared: breadcrumb renderer ─────────────────────────────────────────────── function renderCrumb(path, selector, onNavigate) { const ol = document.querySelector(selector + ' ol'); if (!ol) return; const parts = path ? path.split('/').filter(Boolean) : []; let html = ''; let acc = ''; for (const p of parts) { acc = acc ? acc + '/' + p : p; html += ``; } ol.innerHTML = html; ol.querySelectorAll('a').forEach(a => a.addEventListener('click', ev => { ev.preventDefault(); onNavigate(a.dataset.path); }) ); } // (project type selector moved to Settings tab — handled in settingsPanel block) // ── Dashboard: pin toggle ───────────────────────────────────────────────────── document.querySelectorAll('.btn-pin-project').forEach(btn => { btn.addEventListener('click', async () => { const id = parseInt(btn.dataset.id); const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'pin', id }), }); const data = await res.json(); if (data.ok) location.reload(); else showError(data.error || 'Error'); }); }); // ── Git tab ─────────────────────────────────────────────────────────────────── const gitPanel = document.getElementById('gitPanel'); if (gitPanel) { const pid = gitPanel.dataset.projectId; let gitSubdir = ''; function gitParams(extra = '') { const base = `/api/git?project_id=${pid}${gitSubdir ? '&subdir=' + encodeURIComponent(gitSubdir) : ''}`; return base + (extra ? '&' + extra : ''); } async function loadGitStatus() { const res = await fetch(gitParams('action=status')); const data = await res.json(); if (data.no_git) { if (data.subdirs?.length) { const sel = document.getElementById('gitSubdirSelector'); const opts = document.getElementById('gitSubdirSelect'); opts.innerHTML = data.subdirs.map(s => ``).join(''); sel.classList.remove('d-none'); document.getElementById('gitSubdirUseBtn').onclick = () => { gitSubdir = opts.value; sel.classList.add('d-none'); loadGitStatus(); loadGitLog(); }; } else { document.getElementById('gitNoRepo').classList.remove('d-none'); } return; } if (data.error) { showError(data.error); return; } document.getElementById('gitStatus').classList.remove('d-none'); document.getElementById('gitBranch').innerHTML = `${esc(data.branch)}`; const files = data.files || []; const stash = data.stash_count || 0; let summary = files.length ? `${files.length} changed file${files.length > 1 ? 's' : ''}` : 'Working tree clean'; if (stash) summary += ` · ${stash} stash${stash > 1 ? 'es' : ''}`; document.getElementById('gitStatusSummary').textContent = summary; const div = document.getElementById('gitFiles'); if (files.length) { div.innerHTML = `
    ` + files.map(f => `
    ${esc(f.xy)} ${esc(f.file)}
    `).join('') + `
    `; div.querySelectorAll('.btn-git-diff').forEach(btn => { btn.addEventListener('click', () => showGitDiff('', btn.dataset.file)); }); document.getElementById('gitAddSelectedBtn')?.addEventListener('click', async () => { const checked = [...div.querySelectorAll('.git-file-check:checked')].map(cb => cb.dataset.file); if (!checked.length) { showError('Select files to add first'); return; } const d = await (await gitPost({ action: 'stage', files: checked })).json(); d.ok ? (showSuccess(`Staged ${checked.length} file${checked.length > 1 ? 's' : ''}`), loadGitStatus()) : showError(d.output || 'Stage failed'); }); } else { div.innerHTML = ''; } } async function loadGitLog() { const res = await fetch(gitParams('action=log')); const data = await res.json(); const tbl = document.getElementById('gitLogTable'); if (!data.commits?.length) { tbl.innerHTML = '
    No commits yet.
    '; return; } tbl.innerHTML = `
    ` + data.commits.map(c => `
    ${esc(c.subject)} ${esc(c.rel)}
    ${esc(c.short)} • ${esc(c.author)}
    `).join('') + `
    `; tbl.querySelectorAll('.btn-git-show-commit').forEach(row => { row.addEventListener('click', () => showGitDiff(row.dataset.hash, '')); }); } async function loadGitBranches() { const res = await fetch(gitParams('action=branches')); const data = await res.json(); if (!data.branches) return; const list = document.getElementById('gitBranchList'); list.innerHTML = data.branches.map(b => `
    ${esc(b.name)} ${b.current ? 'current' : ''} ${!b.current ? `` : ''}
    `).join(''); list.querySelectorAll('.btn-checkout').forEach(btn => { btn.addEventListener('click', async () => { const d = await (await gitPost({ action: 'checkout', branch: btn.dataset.branch })).json(); if (d.ok) { showSuccess('Switched to ' + btn.dataset.branch); loadGitStatus(); loadGitBranches(); } else showError(d.output || 'Checkout failed'); }); }); } async function showGitDiff(hash, file) { const params = new URLSearchParams({ action: 'diff' }); if (gitSubdir) params.set('subdir', gitSubdir); if (hash) params.append('hash', hash); if (file) params.append('file', file); const res = await fetch(`/api/git?project_id=${pid}&` + params); const data = await res.json(); document.getElementById('gitDiffContent').innerHTML = colorDiff(data.diff || '(empty)'); document.getElementById('gitDiffTitle').textContent = hash ? `Commit ${hash.substring(0, 7)}` : `Diff: ${file}`; bootstrap.Modal.getOrCreateInstance(document.getElementById('gitDiffModal')).show(); } function colorDiff(raw) { return raw.split('\n').map(line => { if (line.startsWith('+') && !line.startsWith('+++')) return `${esc(line)}`; if (line.startsWith('-') && !line.startsWith('---')) return `${esc(line)}`; if (line.startsWith('@@')) return `${esc(line)}`; if (/^(diff |index |---|[+]{3})/.test(line)) return `${esc(line)}`; return esc(line); }).join('\n'); } function gitPost(body) { if (gitSubdir) body.subdir = gitSubdir; return fetch('/api/git?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); } async function runGitStream(action) { const out = document.getElementById('gitStreamOutput'); out.classList.remove('d-none'); out.textContent = `git ${action}…\n`; const res = await gitPost({ action }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); const lines = buf.split('\n'); buf = lines.pop(); for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const d = JSON.parse(line.slice(6)); if (d.line) out.textContent += d.line; if (d.done) { out.textContent += `\nExit: ${d.exit_code}`; d.exit_code === 0 ? showSuccess(`git ${action} done`) : showError(`git ${action} failed (exit ${d.exit_code})`); loadGitStatus(); if (action === 'pull') loadGitLog(); } if (d.error) showError(d.error); } catch(e) {} } out.scrollTop = out.scrollHeight; } } document.getElementById('gitPullBtn')?.addEventListener('click', () => runGitStream('pull')); document.getElementById('gitPushBtn')?.addEventListener('click', () => runGitStream('push')); document.getElementById('gitCommitBtn')?.addEventListener('click', async () => { const msg = document.getElementById('gitCommitMsg').value.trim(); if (!msg) { showError('Commit message required'); return; } const res = await gitPost({ action: 'commit', message: msg }); const data = await res.json(); if (data.ok) { showSuccess('Committed'); document.getElementById('gitCommitMsg').value = ''; loadGitStatus(); loadGitLog(); } else { showError(data.output || 'Commit failed'); } }); document.getElementById('gitStashBtn')?.addEventListener('click', async () => { const d = await (await gitPost({ action: 'stash' })).json(); d.ok ? (showSuccess('Stashed'), loadGitStatus()) : showError(d.output || 'Error'); }); document.getElementById('gitStashPopBtn')?.addEventListener('click', async () => { const d = await (await gitPost({ action: 'stash_pop' })).json(); d.ok ? (showSuccess('Applied stash'), loadGitStatus()) : showError(d.output || 'Error'); }); document.getElementById('gitResetBtn')?.addEventListener('click', () => { confirmAction('Reset to HEAD? All uncommitted changes will be lost.', async () => { const d = await (await gitPost({ action: 'reset' })).json(); d.ok ? (showSuccess('Reset to HEAD'), loadGitStatus()) : showError(d.output || 'Error'); }); }); document.getElementById('gitCreateBranchBtn')?.addEventListener('click', async () => { const branch = document.getElementById('gitNewBranch').value.trim(); if (!branch) return; const d = await (await gitPost({ action: 'create_branch', branch })).json(); if (d.ok) { showSuccess('Created ' + branch); document.getElementById('gitNewBranch').value = ''; loadGitBranches(); loadGitStatus(); } else { showError(d.output || 'Error'); } }); // Sub-tab switching document.getElementById('gitTabLogBtn')?.addEventListener('click', () => { document.getElementById('gitTabLogBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary'); document.getElementById('gitTabLogBtn').classList.add('active'); document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary'); document.getElementById('gitTabBranchesBtn').classList.remove('active'); document.getElementById('gitLogPanel').classList.remove('d-none'); document.getElementById('gitBranchesPanel').classList.add('d-none'); }); document.getElementById('gitTabBranchesBtn')?.addEventListener('click', () => { document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary'); document.getElementById('gitTabBranchesBtn').classList.add('active'); document.getElementById('gitTabLogBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary'); document.getElementById('gitTabLogBtn').classList.remove('active'); document.getElementById('gitBranchesPanel').classList.remove('d-none'); document.getElementById('gitLogPanel').classList.add('d-none'); loadGitBranches(); }); loadGitStatus(); loadGitLog(); } // ── Config editor (multi-file) ──────────────────────────────────────────────── const configPanel = document.getElementById('configPanel'); if (configPanel) { const pid = configPanel.dataset.projectId; let configCM = null; let configCurrentPath = '_config.yml'; // Discover available config files (root _config*.yml + themes/*/ _config.yml) async function discoverConfigs() { const sel = document.getElementById('configFileSelect'); if (!sel) return; try { const res = await fetch(`/api/files?project_id=${pid}&path=`); const data = await res.json(); const yamls = (data.entries || []) .filter(e => e.type === 'file' && /^_config.*\.ya?ml$/i.test(e.name)) .map(e => ({ path: e.path, label: e.name + (e.name === '_config.yml' ? ' (blog)' : '') })); // Also check themes/ for theme configs try { const tr = await fetch(`/api/files?project_id=${pid}&path=themes`); const td = await tr.json(); for (const d of (td.entries || []).filter(e => e.type === 'dir')) { const cr = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent('themes/' + d.name)}`); const cd = await cr.json(); if ((cd.entries || []).some(e => e.name === '_config.yml')) { yamls.push({ path: 'themes/' + d.name + '/_config.yml', label: d.name + ' theme config' }); } } } catch(e) {} if (yamls.length > 1) { sel.innerHTML = yamls.map(y => `` ).join(''); } } catch(e) {} } function loadConfig(path) { configCurrentPath = path; fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`) .then(r => r.json()) .then(data => { const container = document.getElementById('configEditor'); if (data.error) { container.innerHTML = `
    ${esc(data.error)}
    `; return; } container.innerHTML = ''; if (configCM) { try { configCM.toTextArea(); } catch(e) {} configCM = null; } configCM = CodeMirror(container, { value: data.content, mode: 'yaml', theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, extraKeys: { 'Ctrl-S': saveConfig, 'Cmd-S': saveConfig }, }); configCM.setSize('100%', 'var(--hm-tab-height)'); requestAnimationFrame(() => requestAnimationFrame(() => configCM?.refresh())); }); } async function saveConfig() { if (!configCM) return; const status = document.getElementById('configSaveStatus'); status.textContent = 'Saving…'; const res = await fetch('/api/files?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'write', path: configCurrentPath, content: configCM.getValue() }), }); const data = await res.json(); if (data.ok) { status.textContent = 'Saved.'; showSuccess('Config saved'); } else { status.textContent = ''; showError(data.error || 'Save failed'); } } document.getElementById('configSaveBtn')?.addEventListener('click', saveConfig); document.getElementById('configFileSelect')?.addEventListener('change', function() { loadConfig(this.value); }); discoverConfigs().then(() => loadConfig('_config.yml')); } // ── Full-text search ────────────────────────────────────────────────────────── // ── Tag/category browser ────────────────────────────────────────────────────── const tagsPanel = document.getElementById('tagsPanel'); if (tagsPanel) { const pid = tagsPanel.dataset.projectId; fetch(`/api/tags?project_id=${pid}`) .then(r => r.json()) .then(data => { document.getElementById('tagsLoading').classList.add('d-none'); document.getElementById('tagsContent').classList.remove('d-none'); const tags = data.tags || {}; const cats = data.categories || {}; document.getElementById('tagsCount').textContent = Object.keys(tags).length; document.getElementById('catsCount').textContent = Object.keys(cats).length; document.getElementById('tagCloud').innerHTML = renderTagCloud(tags, 'tag'); document.getElementById('catCloud').innerHTML = renderTagCloud(cats, 'category'); }) .catch(() => { document.getElementById('tagsLoading').textContent = 'Failed to load.'; }); function renderTagCloud(map, kind) { const entries = Object.entries(map); if (!entries.length) return '
    None found.
    '; const max = entries[0][1]; return entries.map(([name, count]) => { const size = Math.max(75, Math.min(160, Math.round(count / max * 100 + 60))); const href = `/project/${pid}?tab=posts&filter=${encodeURIComponent(kind + ':' + name)}`; return `${esc(name)}${count}`; }).join(' '); } } // ── Command history ─────────────────────────────────────────────────────────── document.getElementById('showHistoryBtn')?.addEventListener('click', async () => { const runner = document.getElementById('commandRunner'); if (!runner) return; const pid = runner.dataset.projectId; const res = await fetch(`/api/run?project_id=${pid}`); const data = await res.json(); const list = document.getElementById('cmdHistoryList'); if (!list) return; if (!data.history?.length) { list.innerHTML = '
    No history yet.
    '; } else { list.innerHTML = data.history.map(h => `
    ${esc(h.cmd)} Exit ${h.exit_code}
    ${esc(h.run_at)} ${h.output ? `
    ${esc(h.output.substring(0,500))}
    ` : ''}
    `).join(''); } bootstrap.Modal.getOrCreateInstance(document.getElementById('cmdHistoryModal')).show(); }); // ── Post duplicate ──────────────────────────────────────────────────────────── // Handled via event delegation inside loadPosts(); trigger is .btn-dup-post // ── Settings tab ───────────────────────────────────────────────────────────── const settingsPanel = document.getElementById('settingsPanel'); if (settingsPanel) { const pid = settingsPanel.dataset.projectId; let tplCM = null, snipCM = null; // ── Project rename ── document.getElementById('projectNameSaveBtn')?.addEventListener('click', async () => { const name = document.getElementById('projectNameInput')?.value.trim(); if (!name) { showError('Name required'); return; } const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_name', id: parseInt(pid), name }), }); const data = await res.json(); if (data.ok) showSuccess('Name updated'); else showError(data.error || 'Error'); }); // ── Project type ── document.getElementById('projectTypeSaveBtn')?.addEventListener('click', async () => { const type = document.getElementById('projectTypeSelectSettings')?.value; if (!type) return; const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_type', id: parseInt(pid), type }), }); const data = await res.json(); if (data.ok) location.reload(); else showError(data.error || 'Error'); }); // ── Page directories ── document.getElementById('pageDirsSaveBtn')?.addEventListener('click', async () => { const dirs = document.getElementById('pageDirsInput')?.value ?? ''; const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_setting', id: parseInt(pid), key: 'page_dirs', value: dirs }), }); const data = await res.json(); if (data.ok) showSuccess('Page dirs saved'); else showError(data.error || 'Error'); }); // ── Templates ── async function loadTemplates() { const res = await fetch(`/api/templates?project_id=${pid}`); const data = await res.json(); const list = document.getElementById('templatesList'); if (!data.templates?.length) { list.innerHTML = '
    No templates yet.
    '; return; } list.innerHTML = data.templates.map(t => `
    ${esc(t.name)} ${esc(t.type)}
    `).join(''); list.querySelectorAll('.btn-edit-template').forEach(btn => { btn.addEventListener('click', () => openTemplateModal(btn.dataset.id, btn.dataset.name, btn.dataset.content)); }); list.querySelectorAll('.btn-del-template').forEach(btn => { btn.addEventListener('click', () => confirmAction('Delete this template?', async () => { const r = await fetch('/api/templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }), }); const d = await r.json(); d.ok ? loadTemplates() : showError(d.error || 'Error'); })); }); } function openTemplateModal(id, name, content) { document.getElementById('templateId').value = id || ''; document.getElementById('templateName').value = name || ''; document.getElementById('templateModalTitle').textContent = id ? 'Edit template' : 'New template'; const container = document.getElementById('templateContentEditor'); container.innerHTML = ''; if (tplCM) { try { tplCM.toTextArea(); } catch(e) {} tplCM = null; } tplCM = CodeMirror(container, { value: content || '', mode: 'markdown', theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, }); bootstrap.Modal.getOrCreateInstance(document.getElementById('templateModal')).show(); setTimeout(() => tplCM && tplCM.refresh(), 200); } document.getElementById('newTemplateBtn')?.addEventListener('click', () => openTemplateModal('', '', '')); document.getElementById('templateSaveBtn')?.addEventListener('click', async () => { const id = document.getElementById('templateId').value; const name = document.getElementById('templateName').value.trim(); const content = tplCM ? tplCM.getValue() : ''; if (!name) { showError('Name required'); return; } const body = id ? { action: 'update', id: parseInt(id), name, content } : { action: 'create', project_id: parseInt(pid), name, type: 'post', content }; const res = await fetch('/api/templates', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); const data = await res.json(); if (data.ok) { bootstrap.Modal.getInstance(document.getElementById('templateModal')).hide(); showSuccess(id ? 'Template updated' : 'Template created'); loadTemplates(); } else { showError(data.error || 'Error'); } }); // ── Snippets ── async function loadSnippets() { const res = await fetch(`/api/snippets?project_id=${pid}`); const data = await res.json(); const list = document.getElementById('snippetsList'); if (!data.snippets?.length) { list.innerHTML = '
    No snippets yet.
    '; return; } list.innerHTML = data.snippets.map(s => `
    ${esc(s.name)}
    `).join(''); list.querySelectorAll('.btn-edit-snippet').forEach(btn => { btn.addEventListener('click', () => openSnippetModal(btn.dataset.id, btn.dataset.name, btn.dataset.content)); }); list.querySelectorAll('.btn-del-snippet').forEach(btn => { btn.addEventListener('click', () => confirmAction('Delete this snippet?', async () => { const r = await fetch('/api/snippets', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }), }); const d = await r.json(); d.ok ? loadSnippets() : showError(d.error || 'Error'); })); }); } function openSnippetModal(id, name, content) { document.getElementById('snippetId').value = id || ''; document.getElementById('snippetName').value = name || ''; document.getElementById('snippetModalTitle').textContent = id ? 'Edit snippet' : 'New snippet'; const container = document.getElementById('snippetContentEditor'); container.innerHTML = ''; if (snipCM) { try { snipCM.toTextArea(); } catch(e) {} snipCM = null; } snipCM = CodeMirror(container, { value: content || '', mode: 'markdown', theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, }); bootstrap.Modal.getOrCreateInstance(document.getElementById('snippetModal')).show(); setTimeout(() => snipCM && snipCM.refresh(), 200); } document.getElementById('newSnippetBtn')?.addEventListener('click', () => openSnippetModal('', '', '')); document.getElementById('snippetSaveBtn')?.addEventListener('click', async () => { const id = document.getElementById('snippetId').value; const name = document.getElementById('snippetName').value.trim(); const content = snipCM ? snipCM.getValue() : ''; if (!name) { showError('Name required'); return; } const body = id ? { action: 'update', id: parseInt(id), name, content } : { action: 'create', project_id: parseInt(pid), name, content }; const res = await fetch('/api/snippets', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); const data = await res.json(); if (data.ok) { bootstrap.Modal.getInstance(document.getElementById('snippetModal')).hide(); showSuccess(id ? 'Snippet updated' : 'Snippet created'); loadSnippets(); } else { showError(data.error || 'Error'); } }); if (document.getElementById('templatesList')) loadTemplates(); if (document.getElementById('snippetsList')) loadSnippets(); } // ── Audit log page ──────────────────────────────────────────────────────────── const auditTable = document.getElementById('auditTable'); if (auditTable) { let auditOffset = 0; const auditLimit = 50; async function loadAudit(offset = 0) { auditOffset = offset; const pid = document.getElementById('auditProjectFilter')?.value || ''; const params = new URLSearchParams({ limit: auditLimit, offset }); if (pid) params.append('project_id', pid); const res = await fetch('/api/audit?' + params); const data = await res.json(); if (!data.entries?.length) { auditTable.innerHTML = '
    No entries.
    '; } else { auditTable.innerHTML = `` + data.entries.map(e => ``).join('') + `
    TimeUserProjectActionDetailIP
    ${esc(e.created_at)} ${esc(e.username ?? e.user_id ?? '—')} ${esc(e.project_name ?? (e.project_id ? '#' + e.project_id : '—'))} ${esc(e.action)} ${esc(e.detail ?? '')} ${esc(e.ip ?? '')}
    `; } const total = data.total ?? 0; document.getElementById('auditPrev').classList.toggle('d-none', offset === 0); document.getElementById('auditNext').classList.toggle('d-none', offset + auditLimit >= total); } document.getElementById('auditProjectFilter')?.addEventListener('change', () => loadAudit(0)); document.getElementById('auditPrev')?.addEventListener('click', () => loadAudit(Math.max(0, auditOffset - auditLimit))); document.getElementById('auditNext')?.addEventListener('click', () => loadAudit(auditOffset + auditLimit)); loadAudit(); } // ── Settings: scan paths ────────────────────────────────────────────────────── const addScanPathForm = document.getElementById('addScanPathForm'); if (addScanPathForm) { addScanPathForm.addEventListener('submit', async e => { e.preventDefault(); const fd = new FormData(addScanPathForm); const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'add_scan_path', path: fd.get('path'), depth: parseInt(fd.get('depth')) }), }); const data = await res.json(); data.ok ? location.reload() : showError(data.error || 'Error'); }); document.querySelectorAll('.btn-remove-scan').forEach(btn => { btn.addEventListener('click', () => confirmAction('Remove this scan path?', async () => { const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'remove_scan_path', id: parseInt(btn.dataset.id) }), }); const data = await res.json(); if (data.ok) location.reload(); })); }); document.querySelectorAll('.btn-scan').forEach(btn => { btn.addEventListener('click', async () => { btn.disabled = true; const res = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'scan', path: btn.dataset.path, depth: parseInt(btn.dataset.depth) }), }); const data = await res.json(); btn.disabled = false; if (data.error) { showError(data.error); return; } const resultsDiv = document.getElementById('scanResults'); const listDiv = document.getElementById('scanResultsList'); resultsDiv.classList.remove('d-none'); if (!data.found.length) { listDiv.innerHTML = '

    No projects found.

    '; return; } listDiv.innerHTML = data.found.map(p => `
    ${esc(p.name)} ${esc(p.path)}
    ${esc(p.type_name)}
    `).join(''); listDiv.querySelectorAll('.btn-add-found').forEach(b => { b.addEventListener('click', async () => { const r = await fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name: b.dataset.name, path: b.dataset.path, type: b.dataset.type }), }); const d = await r.json(); if (d.ok) { b.innerHTML = ''; b.disabled = true; b.classList.replace('btn-primary','btn-success'); } else showError(d.error || 'Error'); }); }); }); }); document.getElementById('closeScanResults')?.addEventListener('click', () => { document.getElementById('scanResults').classList.add('d-none'); }); } // ── Scratchpad: per-project notes ───────────────────────────────────────────── (() => { const card = document.getElementById('scratchpadCard'); if (!card) return; const pid = parseInt(card.dataset.projectId); const input = document.getElementById('scratchpadInput'); const status = document.getElementById('scratchpadStatus'); let timer = null; let lastSaved = input.value; const setStatus = (msg, cls = 'text-muted') => { status.className = 'small ms-auto ' + cls; status.textContent = msg; }; const save = async () => { if (input.value === lastSaved) return; setStatus('Saving…'); try { const r = await fetch('/api/scratchpad?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ content: input.value }), }); const j = await r.json(); if (j.ok) { lastSaved = input.value; setStatus('Saved', 'text-success'); setTimeout(() => setStatus(''), 1500); } else { setStatus(j.error || 'Save failed', 'text-danger'); } } catch (e) { setStatus('Save failed', 'text-danger'); } }; input.addEventListener('input', () => { setStatus('Editing…'); clearTimeout(timer); timer = setTimeout(save, 800); }); input.addEventListener('blur', () => { clearTimeout(timer); save(); }); })(); // ── Recent files tab ────────────────────────────────────────────────────────── (() => { const panel = document.getElementById('recentPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const list = document.getElementById('recentList'); const fmtAgo = ts => { const t = Date.parse(ts.replace(' ', 'T') + 'Z'); const s = Math.max(1, Math.floor((Date.now() - t) / 1000)); if (s < 60) return s + 's ago'; if (s < 3600) return Math.floor(s / 60) + 'm ago'; if (s < 86400) return Math.floor(s / 3600) + 'h ago'; if (s < 86400 * 7) return Math.floor(s / 86400) + 'd ago'; return new Date(t).toLocaleDateString(); }; async function load() { const r = await fetch('/api/recent?project_id=' + pid); const d = await r.json(); if (!d.items?.length) { list.innerHTML = '
    No recent files yet — open or save a file to start tracking.
    '; return; } list.innerHTML = d.items.map(it => `
    ${esc(it.name)}
    ${esc(it.dir || '/')}
    ${fmtAgo(it.opened_at)}
    `).join(''); list.querySelectorAll('.btn-edit-recent').forEach(b => { b.addEventListener('click', () => openFileEditor(pid, b.dataset.path, b.dataset.name)); }); list.querySelectorAll('.btn-remove-recent').forEach(b => { b.addEventListener('click', async () => { await fetch('/api/recent?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'remove', path: b.dataset.path }), }); load(); }); }); } document.getElementById('recentClearBtn').addEventListener('click', () => { confirmAction('Clear all recent files for this project?', async () => { await fetch('/api/recent?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'clear' }), }); load(); }); }); load(); })(); // ── Disk usage: dashboard cards + project page badge ───────────────────────── (() => { // Project header pill const badge = document.getElementById('diskBadge'); if (badge) { const pid = badge.dataset.projectId; fetch('/api/disk?project_id=' + pid).then(r => r.json()).then(d => { if (d.size?.human) { document.getElementById('diskBadgeValue').textContent = d.size.human; badge.classList.remove('d-none'); } }); } // Dashboard card pills (bulk) const cards = [...document.querySelectorAll('.project-disk')]; if (cards.length) { const ids = cards.map(c => c.dataset.projectId).join(','); fetch('/api/disk?ids=' + ids).then(r => r.json()).then(d => { cards.forEach(c => { const item = d.items?.[c.dataset.projectId]; if (item?.human) { c.querySelector('.project-disk-value').textContent = item.human; c.classList.remove('d-none'); } }); }); } })(); // ── Activity feed (dashboard) ──────────────────────────────────────────────── (() => { const feed = document.getElementById('activityFeed'); if (!feed) return; const ACTION_LABELS = { git_commit: 'commit', git_push: 'push', git_pull: 'pull', git_merge: 'merge', git_reset: 'reset', git_stage: 'stage', git_unstage: 'unstage', git_discard: 'discard', git_fetch: 'fetch', backup_download: 'backup', link_scan: 'link scan', theme_switch: 'theme switch', theme_clone: 'theme clone', theme_delete: 'theme delete', theme_git_pull: 'theme pull', theme_git_push: 'theme push', theme_git_fetch: 'theme fetch', plugin_install: 'plugin install', plugin_uninstall: 'plugin uninstall', scheduled_build: 'scheduled build', bot_start: 'bot started', bot_stop: 'bot stopped', bot_restart: 'bot restarted', botconfig_streamer_add: 'streamer added', botconfig_streamer_save: 'streamer updated', botconfig_streamer_remove: 'streamer removed', botconfig_rss_add: 'feed added', botconfig_rss_save: 'feed updated', botconfig_rss_remove: 'feed removed', botconfig_mastodon_add: 'mastodon added', botconfig_mastodon_save: 'mastodon updated', botconfig_mastodon_remove: 'mastodon removed', botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated', botconfig_linkedin_page_remove: 'linkedin page removed', botconfig_discord_channel_add: 'discord channel added', botconfig_discord_channel_save: 'discord channel updated', botconfig_discord_channel_remove: 'discord channel removed', linkedin_oauth_connect: 'linkedin connected', botcompose_rss_repost: 'rss repost queued', botcompose_mastodon_post: 'mastodon posted', botcompose_linkedin_post: 'linkedin posted', botcompose_discord_post: 'discord posted', scheduled_post_create: 'post scheduled', scheduled_post_delete: 'scheduled post deleted', file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload', post_create: 'post created', post_delete: 'post deleted', post_publish: 'post published', post_duplicate: 'post duplicated', draft_create: 'draft created', draft_update: 'draft updated', draft_delete: 'draft deleted', draft_publish: 'draft published', command_run: 'command run', project_add: 'project added', project_delete: 'project removed', project_rename: 'project renamed', project_type_change: 'type changed', project_pin: 'pinned', project_unpin: 'unpinned', project_setting: 'setting changed', scan_path_add: 'scan path added', scan_path_delete: 'scan path removed', schedule_create: 'schedule added', schedule_update: 'schedule updated', schedule_delete: 'schedule removed', template_create: 'template added', template_update: 'template updated', template_delete: 'template deleted', snippet_create: 'snippet added', snippet_update: 'snippet updated', snippet_delete: 'snippet deleted', }; const ACTION_ICONS = { git_commit: 'bi-git', git_push: 'bi-arrow-up-circle', git_pull: 'bi-arrow-down-circle', git_fetch: 'bi-arrow-repeat', git_merge: 'bi-sign-merge-right', git_reset: 'bi-arrow-counterclockwise', git_stage: 'bi-plus-circle', git_unstage: 'bi-dash-circle', git_discard: 'bi-x-circle', backup_download: 'bi-download', link_scan: 'bi-link-45deg', theme_switch: 'bi-palette', theme_clone: 'bi-cloud-download', theme_delete: 'bi-trash', theme_git_pull: 'bi-arrow-down-circle', theme_git_push: 'bi-arrow-up-circle', theme_git_fetch: 'bi-arrow-repeat', plugin_install: 'bi-puzzle', plugin_uninstall: 'bi-puzzle', scheduled_build: 'bi-clock', bot_start: 'bi-play-circle', bot_stop: 'bi-stop-circle', bot_restart: 'bi-arrow-clockwise', botconfig_streamer_add: 'bi-twitch', botconfig_streamer_save: 'bi-twitch', botconfig_streamer_remove: 'bi-twitch', botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss', botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon', botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin', botconfig_discord_channel_add: 'bi-discord', botconfig_discord_channel_save: 'bi-discord', botconfig_discord_channel_remove: 'bi-discord', linkedin_oauth_connect: 'bi-linkedin', botcompose_rss_repost: 'bi-arrow-repeat', botcompose_mastodon_post: 'bi-mastodon', botcompose_linkedin_post: 'bi-linkedin', botcompose_discord_post: 'bi-discord', scheduled_post_create: 'bi-calendar-plus', scheduled_post_delete: 'bi-calendar-x', file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload', post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x', post_publish: 'bi-send', post_duplicate: 'bi-files', draft_create: 'bi-pencil-square', draft_update: 'bi-pencil-square', draft_delete: 'bi-trash', draft_publish: 'bi-send', command_run: 'bi-terminal', project_add: 'bi-plus-square', project_delete: 'bi-trash', project_rename: 'bi-pencil', project_type_change: 'bi-arrow-left-right', project_pin: 'bi-pin-fill', project_unpin: 'bi-pin', project_setting: 'bi-sliders', scan_path_add: 'bi-folder-plus', scan_path_delete: 'bi-folder-minus', schedule_create: 'bi-clock', schedule_update: 'bi-clock', schedule_delete: 'bi-clock', template_create: 'bi-file-earmark-text', template_update: 'bi-file-earmark-text', template_delete: 'bi-file-earmark-text', snippet_create: 'bi-code-square', snippet_update: 'bi-code-square', snippet_delete: 'bi-code-square', }; fetch('/api/audit?limit=20').then(r => r.json()).then(d => { if (!d.entries?.length) { feed.innerHTML = '
    No activity yet.
    '; return; } feed.innerHTML = d.entries.map(e => { const label = ACTION_LABELS[e.action] || e.action; const icon = ACTION_ICONS[e.action] || 'bi-circle'; const proj = e.project_name ? `${esc(e.project_name)}` : ''; const detail = e.detail ? `${esc(e.detail)}` : ''; return `
    ${esc(label)} ${proj} ${detail} ${esc(e.created_at)}
    `; }).join(''); }); })(); // ── Broken link checker (links tab) ────────────────────────────────────────── (() => { const panel = document.getElementById('linksPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const wrap = document.getElementById('linksTableWrap'); const info = document.getElementById('linksRunInfo'); const onlyB = document.getElementById('linksOnlyBroken'); const btn = document.getElementById('linksScanBtn'); function statusBadge(code, error) { if (error) return `err ${esc(error)}`; if (code == null) return `?`; if (code >= 400) return `${code}`; if (code >= 300) return `${code}`; return `${code}`; } async function load() { const url = '/api/links?project_id=' + pid + (onlyB.checked ? '&broken_only=1' : ''); const d = await (await fetch(url)).json(); if (!d.run) { info.textContent = 'No scan run yet.'; wrap.innerHTML = ''; return; } const r = d.run; info.innerHTML = `Last scan ${esc(r.finished_at || r.started_at)} — ${r.total_links} links, ${r.broken} broken`; if (!d.results.length) { wrap.innerHTML = `
    ${onlyB.checked ? 'No broken links 🎉' : 'No links recorded.'}
    `; return; } wrap.innerHTML = `${d.results.map(x => ` `).join('')}
    StatusURLSource
    ${statusBadge(x.status_code, x.error)} ${esc(x.url)} ${esc(x.source)}
    `; } btn.addEventListener('click', async () => { btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = 'Scanning…'; info.textContent = 'Scanning… this can take a while for large projects.'; wrap.innerHTML = ''; try { const r = await fetch('/api/links?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'scan' }), }); const d = await r.json(); if (d.error) showError(d.error); else showSuccess(`Scan done — ${d.broken} broken of ${d.total}`); await load(); } catch (e) { showError(e.message); } finally { btn.disabled = false; btn.innerHTML = orig; } }); onlyB.addEventListener('change', load); load(); })(); // ── Themes tab ─────────────────────────────────────────────────────────────── (() => { const panel = document.getElementById('themesPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const list = document.getElementById('themesList'); function gitChip(g) { if (!g.has_git) return 'no git'; const parts = [` ${esc(g.branch || '?')}`]; if (g.dirty) parts.push('● dirty'); if (g.ahead) parts.push(`↑${g.ahead}`); if (g.behind) parts.push(`↓${g.behind}`); return `${parts.join(' ')}`; } function renderTheme(t) { const g = t.git; return `
    ${esc(t.name)}
    ${t.active ? 'active' : ''}
    ${gitChip(g)}
    ${g.remote ? `
    ${esc(g.remote)}
    ` : ''} ${g.commit ? `
    ${esc(g.commit)}
    ` : ''}
    `; } async function load() { list.innerHTML = '
    Loading…
    '; const d = await (await fetch('/api/themes?project_id=' + pid)).json(); if (d.no_themes_dir) { list.innerHTML = '
    No themes/ directory found in this project.
    '; return; } if (!d.themes.length) { list.innerHTML = '
    No themes installed yet. Clone one from a git URL.
    '; return; } list.innerHTML = d.themes.map(renderTheme).join(''); list.querySelectorAll('.btn-theme-switch').forEach(b => { b.addEventListener('click', async () => { const r = await fetch('/api/themes?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'switch', name: b.dataset.name }), }); const j = await r.json(); if (j.ok) { showSuccess('Theme switched to ' + b.dataset.name); load(); } else showError(j.error || 'Switch failed'); }); }); list.querySelectorAll('.btn-theme-git').forEach(b => { b.addEventListener('click', async () => { b.disabled = true; const r = await fetch('/api/themes?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'git', name: b.dataset.name, op: b.dataset.op }), }); const j = await r.json(); b.disabled = false; if (j.ok) { showSuccess(b.dataset.op + ' ok'); load(); } else showError(j.error || j.log || 'Git op failed'); }); }); list.querySelectorAll('.btn-theme-delete').forEach(b => { b.addEventListener('click', () => { confirmAction('Delete theme "' + b.dataset.name + '"? Files will be removed from disk.', async () => { const r = await fetch('/api/themes?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', name: b.dataset.name }), }); const j = await r.json(); if (j.ok) { showSuccess('Deleted'); load(); } else showError(j.error || 'Delete failed'); }); }); }); } document.getElementById('cloneThemeSubmit').addEventListener('click', async () => { const url = document.getElementById('cloneThemeUrl').value.trim(); const name = document.getElementById('cloneThemeName').value.trim(); const log = document.getElementById('cloneThemeLog'); if (!url) { log.textContent = 'URL required'; return; } log.textContent = 'Cloning…'; const r = await fetch('/api/themes?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'clone', url, name }), }); const j = await r.json(); log.textContent = j.log || (j.error || 'Done'); if (j.ok) { showSuccess('Cloned theme ' + j.name); bootstrap.Modal.getInstance(document.getElementById('cloneThemeModal')).hide(); document.getElementById('cloneThemeUrl').value = ''; document.getElementById('cloneThemeName').value = ''; load(); } else { showError(j.error || 'Clone failed'); } }); load(); })(); // ── Plugins tab ────────────────────────────────────────────────────────────── (() => { const panel = document.getElementById('pluginsPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const list = document.getElementById('pluginsList'); const log = document.getElementById('pluginInstallLog'); function renderPlugin(p) { const installed = p.installed ? `v${esc(p.installed)}` : `not installed (run npm install)`; return `
    ${esc(p.name)}
    ${installed} range ${esc(p.version)}
    ${p.description ? `

    ${esc(p.description)}

    ` : ''}
    npm ${p.repository ? ` · repo` : ''} ${p.homepage && p.homepage !== p.repository ? ` · docs` : ''}
    `; } async function load() { list.innerHTML = '
    Loading…
    '; const d = await (await fetch('/api/plugins?project_id=' + pid)).json(); if (d.no_package_json) { list.innerHTML = '
    No package.json found in this project.
    '; return; } if (!d.plugins.length) { list.innerHTML = '
    No hexo-* plugins installed.
    '; return; } list.innerHTML = d.plugins.map(renderPlugin).join(''); list.querySelectorAll('.btn-plugin-uninstall').forEach(b => { b.addEventListener('click', () => { confirmAction('Uninstall ' + b.dataset.name + '?', async () => { b.disabled = true; log.textContent = 'Uninstalling…'; log.classList.remove('d-none'); const r = await fetch('/api/plugins?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'uninstall', name: b.dataset.name }), }); const j = await r.json(); log.textContent = j.log || j.error || 'Done'; if (j.ok) { showSuccess('Uninstalled'); load(); } else showError(j.error || 'Uninstall failed'); }); }); }); } document.getElementById('pluginInstallBtn').addEventListener('click', async () => { const name = document.getElementById('pluginInstallName').value.trim(); if (!name) return; log.textContent = 'Installing ' + name + '…'; log.classList.remove('d-none'); const r = await fetch('/api/plugins?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'install', name }), }); const j = await r.json(); log.textContent = j.log || j.error || 'Done'; if (j.ok) { showSuccess('Installed ' + name); document.getElementById('pluginInstallName').value = ''; load(); } else { showError(j.error || 'Install failed'); } }); load(); })(); // ── Scheduled builds (settings tab) ────────────────────────────────────────── (() => { const panel = document.getElementById('settingsPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const list = document.getElementById('schedulesList'); const newBtn = document.getElementById('newScheduleBtn'); if (!list || !newBtn) return; async function api(body) { const r = await fetch('/api/schedules?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body), }); return r.json(); } function bindRow(row) { const id = parseInt(row.dataset.id); row.querySelector('.schedule-save').addEventListener('click', async () => { const j = await api({ action: 'update', id, cmd_id: row.querySelector('.schedule-cmd').value, cron: row.querySelector('.schedule-cron').value.trim(), is_enabled: row.querySelector('.schedule-enabled').checked, }); j.ok ? showSuccess('Saved') : showError(j.error || 'Save failed'); }); row.querySelector('.schedule-enabled').addEventListener('change', async (e) => { await api({ action: 'update', id, is_enabled: e.target.checked }); }); row.querySelector('.schedule-delete').addEventListener('click', () => { confirmAction('Delete this schedule?', async () => { const j = await api({ action: 'delete', id }); if (j.ok) row.remove(); }); }); } list.querySelectorAll('.schedule-row').forEach(bindRow); newBtn.addEventListener('click', async () => { const firstCmd = panel.querySelector('.schedule-cmd')?.options[0]?.value || document.querySelector('.btn-run-cmd')?.dataset.cmd || 'generate'; const j = await api({ action: 'create', cmd_id: firstCmd, cron: '0 3 * * *', is_enabled: 1 }); if (j.id) location.reload(); else showError(j.error || 'Create failed'); }); })(); // ── Server-log analytics — Settings tab controls ──────────────────────────── (() => { const root = document.getElementById('analyticsSettings'); if (!root) return; const pid = parseInt(root.dataset.projectId); const status = document.getElementById('analyticsStatus'); async function saveSetting(key, value) { return fetch('/api/projects', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update_setting', id: pid, key, value }), }).then(r => r.json()); } document.getElementById('analyticsSaveBtn').addEventListener('click', async () => { await saveSetting('analytics_log_path', document.getElementById('analyticsLogPath').value.trim()); await saveSetting('analytics_log_format', document.getElementById('analyticsLogFormat').value); await saveSetting('analytics_log_filter', document.getElementById('analyticsLogFilter').value.trim()); showSuccess('Saved'); }); document.getElementById('analyticsImportBtn').addEventListener('click', async () => { const btn = document.getElementById('analyticsImportBtn'); btn.disabled = true; const orig = btn.innerHTML; btn.innerHTML = 'Importing…'; status.textContent = 'Importing…'; const r = await fetch('/api/analytics_import?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'run' }), }); const j = await r.json(); btn.disabled = false; btn.innerHTML = orig; if (j.ok) { showSuccess(`Imported ${j.imported} new rows (skipped ${j.skipped})`); status.textContent = `Last import just now — added ${j.imported} rows, skipped ${j.skipped}.`; } else { showError(j.error || 'Import failed'); status.textContent = 'Last import failed: ' + (j.error || 'unknown error'); } }); document.getElementById('analyticsResetBtn').addEventListener('click', () => { confirmAction('Reset import position? Next "Import now" will re-scan the log from the start.', async () => { const r = await fetch('/api/analytics_import?project_id=' + pid, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'reset' }), }); const j = await r.json(); if (j.ok) showSuccess('Position reset'); else showError(j.error || 'Reset failed'); }); }); })(); // ── Analytics (project Analytics tab) ──────────────────────────────────────── (() => { const panel = document.getElementById('analyticsPanel'); if (!panel) return; const pid = parseInt(panel.dataset.projectId); const body = document.getElementById('analyticsBody'); const range = document.getElementById('analyticsRange'); const refresh = document.getElementById('analyticsRefreshBtn'); function renderEmpty() { body.innerHTML = `
    No visits recorded yet. Configure the access-log path below and click Import now to load history.
    `; } function delta(cur, prev) { if (prev === 0) return cur === 0 ? { txt: '', cls: 'text-muted' } : { txt: 'new', cls: 'text-success' }; const pct = Math.round((cur - prev) / prev * 100); if (pct === 0) return { txt: '0%', cls: 'text-muted' }; if (pct > 0) return { txt: '+' + pct + '%', cls: 'text-success' }; return { txt: pct + '%', cls: 'text-danger' }; } function dayKey(date) { return date.toISOString().slice(0, 10); } // SVG line chart with current + previous-period overlay. function renderLineChart(series, prevSeries, days) { const w = 720, h = 160, padL = 30, padR = 8, padT = 8, padB = 22; const innerW = w - padL - padR; const innerH = h - padT - padB; const today = new Date(); today.setHours(0,0,0,0); const cur = {}; series.forEach(s => cur[s.day] = s.views); const prv = {}; prevSeries.forEach(s => prv[s.day] = s.views); // Build full-day arrays so missing days appear as 0. const curArr = [], prvArr = []; for (let i = days - 1; i >= 0; i--) { const d = new Date(today); d.setDate(today.getDate() - i); const dPrev = new Date(today); dPrev.setDate(today.getDate() - i - days); curArr.push(cur[dayKey(d)] || 0); prvArr.push(prv[dayKey(dPrev)] || 0); } const max = Math.max(1, ...curArr, ...prvArr); const stepX = days > 1 ? innerW / (days - 1) : innerW; const toXY = (i, v) => [padL + i * stepX, padT + innerH - (v / max) * innerH]; const path = (arr) => arr.map((v, i) => { const [x, y] = toXY(i, v); return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1); }).join(' '); const fill = curArr.map((v, i) => { const [x, y] = toXY(i, v); return (i === 0 ? `M${x.toFixed(1)},${(padT+innerH).toFixed(1)} L${x.toFixed(1)},${y.toFixed(1)}` : ` L${x.toFixed(1)},${y.toFixed(1)}`); }).join('') + ` L${(padL + innerW).toFixed(1)},${(padT+innerH).toFixed(1)} Z`; // Y-axis ticks (0 / max/2 / max) const ticks = [0, Math.round(max / 2), max].map(v => { const y = padT + innerH - (v / max) * innerH; return ` ${v}`; }).join(''); // X-axis labels — first/middle/last const labels = [0, Math.floor((days - 1) / 2), days - 1].map(i => { const d = new Date(today); d.setDate(today.getDate() - (days - 1 - i)); const x = padL + i * stepX; return `${ d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) }`; }).join(''); return ` ${ticks} ${labels} `; } // 24-hour bar chart function renderHours(hours) { const byHour = {}; hours.forEach(h => byHour[h.hour] = h.views); const max = Math.max(1, ...Object.values(byHour)); const w = 360, h = 64, gap = 2, barW = (w - 23 * gap) / 24; const bars = []; for (let i = 0; i < 24; i++) { const v = byHour[i] || 0; const bh = (v / max) * (h - 14); const x = i * (barW + gap); const y = h - 12 - bh; bars.push(``); } const labels = [0, 6, 12, 18].map(i => { const x = i * (barW + gap) + barW / 2; return `${i}h`; }).join(''); return ` ${bars.join('')}${labels} `; } function renderTable(rows, html) { if (!rows.length) return '
    '; return html; } async function load() { body.innerHTML = '
    Loading…
    '; const days = parseInt(range.value); const r = await fetch(`/api/analytics?project_id=${pid}&days=${days}`); const d = await r.json(); if (d.error) { body.innerHTML = `
    ${esc(d.error)}
    `; return; } if (d.all_time.views === 0) { renderEmpty(); return; } const dV = delta(d.window.views, d.previous.views); const dU = delta(d.window.uniques, d.previous.uniques); const top = d.top_pages.map(p => ` ${esc(p.path)} ${p.views} ${p.uniques} `).join(''); const refs = d.top_refs.length ? d.top_refs.map(r => ` ${esc(r.referrer)} ${r.views} `).join('') : ''; const rows404 = (d.top_404s || []).map(r => ` ${esc(r.path)} ${r.hits} ${esc((r.last_hit || '').replace('T', ' ').slice(0, 16))} `).join(''); body.innerHTML = `
    Views (last ${d.days}d)
    ${d.window.views}
    ${dV.txt} vs prev ${d.days}d (${d.previous.views})
    Unique visitors
    ${d.window.uniques}
    ${dU.txt} vs prev (${d.previous.uniques})
    All-time views
    ${d.all_time.views}
    ${d.all_time.uniques} uniques
    Tracking since
    ${esc((d.all_time.first_seen || '—').slice(0, 10))}
    last hit ${esc((d.all_time.last_seen || '—').slice(0, 16).replace('T', ' '))}
    last ${d.days}d — — previous ${d.days}d
    ${renderLineChart(d.series, d.prev_series || [], d.days)}
    Top pages
    ${top}
    PathViewsUniques
    Referrers
    ${renderTable(d.top_refs, `${refs}
    FromViews
    `)}
    Hour of day
    ${renderHours(d.hours || [])}
    ${rows404 ? `
    Top 404s in this window
    ${rows404}
    PathHitsLast seen
    ` : ''}`; } range.addEventListener('change', load); refresh?.addEventListener('click', load); load(); })(); // ── Project sidebar collapse toggle ────────────────────────────────────────── (() => { const sb = document.getElementById('projectSidebar'); if (!sb) return; const KEY = 'hackmancms_sidebar_collapsed'; if (localStorage.getItem(KEY) === '1') sb.classList.add('collapsed'); document.getElementById('sidebarToggle')?.addEventListener('click', () => { sb.classList.toggle('collapsed'); localStorage.setItem(KEY, sb.classList.contains('collapsed') ? '1' : '0'); }); })(); // ── Keyboard shortcuts ─────────────────────────────────────────────────────── (() => { let chord = null; let chordTimer = null; const isTyping = () => { const el = document.activeElement; if (!el) return false; if (el.isContentEditable) return true; const tag = el.tagName; return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; }; function handle(key) { if (chord === 'g') { chord = null; if (key === 'd') location.href = '/'; else if (key === 's') location.href = '/settings'; else if (key === 'a') location.href = '/audit'; return; } if (key === '?') { const m = document.getElementById('shortcutsModal'); if (m) bootstrap.Modal.getOrCreateInstance(m).show(); return; } if (key === 'g') { chord = 'g'; clearTimeout(chordTimer); chordTimer = setTimeout(() => { chord = null; }, 1200); return; } // Project page only if (key === 'n') { const btn = document.getElementById('newItemBtn'); if (btn) { btn.click(); return; } } if (key === 'b') { const btn = document.querySelector('.btn-run-cmd[data-cmd="generate"]'); if (btn) { btn.click(); showSuccess('Triggered generate'); return; } } } document.addEventListener('keydown', e => { if (e.ctrlKey || e.metaKey || e.altKey) return; if (isTyping()) return; if (e.key === '?') { e.preventDefault(); handle('?'); return; } if (/^[a-z]$/.test(e.key)) handle(e.key); }); })(); // ── Restore persisted editor tabs on page load ─────────────────────────────── window.addEventListener('DOMContentLoaded', () => { setTimeout(() => { try { _restoreTabState(); } catch(e) {} }, 50); }); // ── Bot: control panel ──────────────────────────────────────────────────────── (function () { const panel = document.getElementById('botControlPanel'); if (!panel) return; const pid = panel.dataset.projectId; const dot = document.getElementById('botStatusDot'); const txt = document.getElementById('botStatusText'); const since = document.getElementById('botSince'); const svc = document.getElementById('botService'); const outputWrap = document.getElementById('botCmdOutputWrap'); const output = document.getElementById('botCmdOutput'); function setStatus(data) { const active = data.active; dot.style.background = active ? '#198754' : '#dc3545'; txt.textContent = active ? 'Running' : (data.status || 'Stopped'); since.textContent = data.since ? 'since ' + data.since : ''; svc.textContent = data.service ? data.service + '.service' : ''; } async function loadStatus() { try { const r = await fetch('/api/botcontrol?action=status&project_id=' + pid); const d = await r.json(); if (d.error) { txt.textContent = d.error; return; } setStatus(d); } catch (e) { txt.textContent = 'Error loading status'; } } async function runAction(action) { outputWrap.classList.remove('d-none'); output.textContent = action + '…'; try { const r = await fetch('/api/botcontrol', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action }), }); const d = await r.json(); output.textContent = d.output || (d.ok ? 'Done.' : 'Failed.'); setStatus(d); } catch (e) { output.textContent = 'Request failed: ' + e.message; } } document.getElementById('botStartBtn').addEventListener('click', () => runAction('start')); document.getElementById('botStopBtn').addEventListener('click', () => runAction('stop')); document.getElementById('botRestartBtn').addEventListener('click', () => runAction('restart')); document.getElementById('botRefreshStatusBtn').addEventListener('click', loadStatus); document.getElementById('botClearOutputBtn').addEventListener('click', () => { output.textContent = ''; outputWrap.classList.add('d-none'); }); loadStatus(); })(); // ── Bot: config editor ──────────────────────────────────────────────────────── (function () { const panel = document.getElementById('botConfigPanel'); if (!panel) return; const pid = panel.dataset.projectId; function rgbToHex([r, g, b]) { return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); } function hexToRgb(hex) { const n = parseInt(hex.slice(1), 16); return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; } let _config = null; async function loadConfig() { const r = await fetch('/api/botconfig?action=get&project_id=' + pid); const d = await r.json(); if (d.error) { showError(d.error); return; } _config = d.config; renderStreamers(_config.twitch || {}); renderFeeds(_config.rss || {}); renderMastodon(_config.mastodon || {}); renderDiscordChannels(_config.discord_channels || {}); renderLinkedinPages(_config.linkedin?.pages || {}); renderLinkedinConnection(_config.linkedin || {}); } // ── Streamers ──────────────────────────────────────────────────────────────── function renderStreamers(twitch) { const list = document.getElementById('streamerList'); const names = Object.keys(twitch); if (!names.length) { list.innerHTML = '
    No streamers configured.
    '; return; } list.innerHTML = names.map(name => { const s = twitch[name]; const hex = rgbToHex(s.color || [255, 255, 255]); return `
    ${esc(name)} ch: ${esc(String(s.channel_id ?? '–'))} ${s.role_id ? `role: ${esc(String(s.role_id))}` : ''}
    `; }).join(''); list.querySelectorAll('.edit-streamer-btn').forEach(b => b.addEventListener('click', () => openStreamerModal('edit', b.dataset.name))); list.querySelectorAll('.del-streamer-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_streamer', b.dataset.name, `Remove streamer "${b.dataset.name}"?`, 'Streamer removed'))); } function openStreamerModal(mode, name = '') { const s = (mode === 'edit' && _config?.twitch?.[name]) || {}; document.getElementById('streamerModalTitle').textContent = mode === 'add' ? 'Add Streamer' : 'Edit Streamer'; document.getElementById('streamerModalMode').value = mode; document.getElementById('streamerName').value = name; document.getElementById('streamerName').disabled = mode === 'edit'; document.getElementById('streamerChannelId').value = s.channel_id ?? ''; document.getElementById('streamerRoleId').value = s.role_id ?? ''; document.getElementById('streamerColor').value = rgbToHex(s.color || [100, 65, 165]); document.getElementById('streamerModalError').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).show(); } document.getElementById('addStreamerBtn').addEventListener('click', () => openStreamerModal('add')); document.getElementById('streamerModalSaveBtn').addEventListener('click', async () => { const mode = document.getElementById('streamerModalMode').value; const name = document.getElementById('streamerName').value.trim(); const errEl = document.getElementById('streamerModalError'); errEl.classList.add('d-none'); if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; } const ok = await apiPost({ action: mode === 'add' ? 'add_streamer' : 'save_streamer', name, data: { channel_id: document.getElementById('streamerChannelId').value.trim(), role_id: document.getElementById('streamerRoleId').value.trim(), color: hexToRgb(document.getElementById('streamerColor').value), }}, errEl); if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).hide(); showSuccess('Streamer saved'); loadConfig(); } }); // ── RSS feeds ──────────────────────────────────────────────────────────────── function renderFeeds(rss) { const list = document.getElementById('rssList'); const names = Object.keys(rss); if (!names.length) { list.innerHTML = '
    No RSS feeds configured.
    '; return; } list.innerHTML = names.map(name => { const f = rss[name]; const hex = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]); const activeBadge = f.active ? 'active' : 'paused'; const acct = f.mastodon_account ? ` ${esc(f.mastodon_account)}` : ''; const liPage = f.linkedin_page ? ` ${esc(f.linkedin_page)}` : ''; return `
    ${esc(name)}${activeBadge} ${acct}${liPage} ${esc(f.rss_url || '–')}
    `; }).join(''); list.querySelectorAll('.repost-rss-btn').forEach(b => b.addEventListener('click', () => { const name = b.dataset.name; confirmAction(`Repeat last post for feed "${name}"?\nThe bot will repost it on the next poll (within 5 min).`, async () => { const r = await fetch('/api/botcompose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action: 'rss_repost', feed_name: name }), }); const d = await r.json(); if (d.ok) showSuccess(d.message || 'Repost queued — bot will post on next poll'); else showError(d.error || 'Failed'); }); })); list.querySelectorAll('.edit-rss-btn').forEach(b => b.addEventListener('click', () => openRssModal('edit', b.dataset.name))); list.querySelectorAll('.del-rss-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_rss', b.dataset.name, `Remove RSS feed "${b.dataset.name}"?`, 'Feed removed'))); } function populateSelect(id, options, selected) { const sel = document.getElementById(id); sel.innerHTML = '' + options.map(([v, l]) => ``).join(''); } function openRssModal(mode, name = '') { const f = (mode === 'edit' && _config?.rss?.[name]) || {}; document.getElementById('rssModalTitle').textContent = mode === 'add' ? 'Add RSS Feed' : 'Edit RSS Feed'; document.getElementById('rssModalMode').value = mode; document.getElementById('rssName').value = name; document.getElementById('rssName').disabled = mode === 'edit'; document.getElementById('rssUrl').value = f.rss_url ?? ''; document.getElementById('rssChannelId').value = f.channel_id ?? ''; document.getElementById('rssRoleId').value = f.role_id ?? ''; document.getElementById('rssColor').value = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]); document.getElementById('rssActive').checked = f.active !== false; populateSelect('rssMastodonAccount', Object.entries(_config?.mastodon || {}).map(([k, v]) => [k, v.label || k]), f.mastodon_account || ''); populateSelect('rssLinkedinPage', Object.entries(_config?.linkedin?.pages || {}) .filter(([, v]) => (v.type ?? 'organization') === 'personal') .map(([k, v]) => [k, v.label || k]), f.linkedin_page || ''); document.getElementById('rssModalError').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).show(); } document.getElementById('addRssBtn').addEventListener('click', () => openRssModal('add')); document.getElementById('rssModalSaveBtn').addEventListener('click', async () => { const mode = document.getElementById('rssModalMode').value; const name = document.getElementById('rssName').value.trim(); const errEl = document.getElementById('rssModalError'); errEl.classList.add('d-none'); if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; } const ok = await apiPost({ action: mode === 'add' ? 'add_rss' : 'save_rss', name, data: { rss_url: document.getElementById('rssUrl').value.trim(), channel_id: document.getElementById('rssChannelId').value.trim(), role_id: document.getElementById('rssRoleId').value.trim(), color: hexToRgb(document.getElementById('rssColor').value), active: document.getElementById('rssActive').checked, mastodon_account: document.getElementById('rssMastodonAccount').value, linkedin_page: document.getElementById('rssLinkedinPage').value, }}, errEl); if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).hide(); showSuccess('Feed saved'); loadConfig(); } }); // ── Mastodon accounts ───────────────────────────────────────────────────────── function renderMastodon(mastodon) { const list = document.getElementById('mastodonList'); const names = Object.keys(mastodon); if (!names.length) { list.innerHTML = '
    No accounts configured.
    '; return; } list.innerHTML = names.map(name => { const a = mastodon[name]; return `
    ${esc(a.label || name)} MASTODON_TOKEN_${esc(name.toUpperCase())} ${esc(a.api_base_url || '')}
    `; }).join(''); list.querySelectorAll('.compose-mastodon-btn').forEach(b => b.addEventListener('click', () => openComposeModal('mastodon', b.dataset.name, b.dataset.label))); list.querySelectorAll('.edit-mastodon-btn').forEach(b => b.addEventListener('click', () => openMastodonModal('edit', b.dataset.name))); list.querySelectorAll('.del-mastodon-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_mastodon', b.dataset.name, `Remove account "${b.dataset.name}"?`, 'Account removed'))); } function openMastodonModal(mode, name = '') { const a = (mode === 'edit' && _config?.mastodon?.[name]) || {}; document.getElementById('mastodonModalTitle').textContent = mode === 'add' ? 'Add Mastodon Account' : 'Edit Mastodon Account'; document.getElementById('mastodonModalMode').value = mode; document.getElementById('mastodonName').value = name; document.getElementById('mastodonName').disabled = mode === 'edit'; document.getElementById('mastodonLabel').value = a.label ?? ''; document.getElementById('mastodonBase').value = a.api_base_url ?? ''; document.getElementById('mastodonModalError').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).show(); } document.getElementById('addMastodonBtn').addEventListener('click', () => openMastodonModal('add')); document.getElementById('mastodonModalSaveBtn').addEventListener('click', async () => { const mode = document.getElementById('mastodonModalMode').value; const name = document.getElementById('mastodonName').value.trim(); const errEl = document.getElementById('mastodonModalError'); errEl.classList.add('d-none'); if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; } const ok = await apiPost({ action: mode === 'add' ? 'add_mastodon' : 'save_mastodon', name, data: { label: document.getElementById('mastodonLabel').value.trim(), api_base_url: document.getElementById('mastodonBase').value.trim(), }}, errEl); if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).hide(); showSuccess('Account saved'); loadConfig(); } }); // ── Discord channels ────────────────────────────────────────────────────────── function renderDiscordChannels(channels) { const list = document.getElementById('discordChannelList'); if (!list) return; const names = Object.keys(channels); if (!names.length) { list.innerHTML = '
    No channels configured.
    '; return; } list.innerHTML = names.map(name => { const ch = channels[name]; return `
    ${esc(ch.label || name)} DISCORD_WEBHOOK_${esc(name.toUpperCase())}
    `; }).join(''); list.querySelectorAll('.edit-discord-btn').forEach(b => b.addEventListener('click', () => openDiscordChannelModal('edit', b.dataset.name))); list.querySelectorAll('.del-discord-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_discord_channel', b.dataset.name, `Remove channel "${b.dataset.name}"?`, 'Channel removed'))); } function openDiscordChannelModal(mode, name = '') { const ch = (mode === 'edit' && _config?.discord_channels?.[name]) || {}; document.getElementById('discordChannelModalTitle').textContent = mode === 'add' ? 'Add Discord Channel' : 'Edit Discord Channel'; document.getElementById('discordChannelModalMode').value = mode; document.getElementById('discordChannelName').value = name; document.getElementById('discordChannelName').disabled = mode === 'edit'; document.getElementById('discordChannelLabel').value = ch.label ?? ''; document.getElementById('discordChannelModalError').classList.add('d-none'); bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).show(); } document.getElementById('addDiscordChannelBtn').addEventListener('click', () => openDiscordChannelModal('add')); document.getElementById('discordChannelModalSaveBtn').addEventListener('click', async () => { const mode = document.getElementById('discordChannelModalMode').value; const name = document.getElementById('discordChannelName').value.trim(); const errEl = document.getElementById('discordChannelModalError'); errEl.classList.add('d-none'); if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; } const ok = await apiPost({ action: mode === 'add' ? 'add_discord_channel' : 'save_discord_channel', name, data: { label: document.getElementById('discordChannelLabel').value.trim() }, }, errEl); if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).hide(); showSuccess('Channel saved'); loadConfig(); } }); // ── LinkedIn pages ──────────────────────────────────────────────────────────── function renderLinkedinConnection(li) { const label = document.getElementById('liConnectionLabel'); const expiry = document.getElementById('liTokenExpiry'); const connected = li.access_token === '***'; label.textContent = connected ? 'Connected to LinkedIn' : 'Not connected'; label.className = 'small fw-semibold ' + (connected ? 'text-success' : 'text-muted'); expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : ''; document.getElementById('liConnectBtn').classList.toggle('d-none', connected); document.getElementById('liTestPostBtn').classList.toggle('d-none', !connected); document.getElementById('liReconnectBtn').classList.toggle('d-none', !connected); } async function _liOAuthRedirect() { const r = await fetch('/api/linkedin_auth?project_id=' + pid); const d = await r.json(); if (d.error) { showError(d.error); return; } window.location.href = d.url; } document.getElementById('liConnectBtn').addEventListener('click', _liOAuthRedirect); document.getElementById('liReconnectBtn').addEventListener('click', _liOAuthRedirect); document.getElementById('liTestPostBtn').addEventListener('click', () => { const pages = _config?.linkedin?.pages || {}; const personalKey = Object.keys(pages).find(k => pages[k].type === 'personal'); if (!personalKey) { showError('No personal LinkedIn profile configured — add one under LinkedIn Pages.'); return; } openComposeModal('linkedin', personalKey, pages[personalKey].label || personalKey); }); function renderLinkedinPages(pages) { const list = document.getElementById('linkedinPagesList'); const names = Object.keys(pages); if (!names.length) { list.innerHTML = '
    No profiles configured.
    '; return; } list.innerHTML = names.map(name => { const p = pages[name]; const isPersonal = p.type === 'personal'; const badge = isPersonal ? `Personal` : `Org (posting not supported)`; const composeBtn = isPersonal ? `` : ''; return `
    ${esc(p.label || name)} ${badge}
    ${composeBtn}
    `; }).join(''); list.querySelectorAll('.compose-linkedin-btn').forEach(b => b.addEventListener('click', () => openComposeModal('linkedin', b.dataset.name, b.dataset.label))); list.querySelectorAll('.edit-lipage-btn').forEach(b => b.addEventListener('click', () => openLinkedinPageModal('edit', b.dataset.name))); list.querySelectorAll('.del-lipage-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_linkedin_page', b.dataset.name, `Remove page "${b.dataset.name}"?`, 'Page removed'))); } function _liPageTypeToggle() { const isPersonal = document.getElementById('linkedinPageType').value === 'personal'; document.getElementById('linkedinPageOrgIdRow').classList.toggle('d-none', isPersonal); } function openLinkedinPageModal(mode, name = '') { const p = (mode === 'edit' && _config?.linkedin?.pages?.[name]) || {}; document.getElementById('linkedinPageModalTitle').textContent = mode === 'add' ? 'Add LinkedIn Page' : 'Edit LinkedIn Page'; document.getElementById('linkedinPageModalMode').value = mode; document.getElementById('linkedinPageName').value = name; document.getElementById('linkedinPageName').disabled = mode === 'edit'; document.getElementById('linkedinPageLabel').value = p.label ?? ''; document.getElementById('linkedinPageType').value = p.type ?? 'organization'; document.getElementById('linkedinPageOrgId').value = p.organization_id ?? ''; document.getElementById('linkedinPageModalError').classList.add('d-none'); _liPageTypeToggle(); bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).show(); } document.getElementById('linkedinPageType').addEventListener('change', _liPageTypeToggle); document.getElementById('addLinkedinPageBtn').addEventListener('click', () => openLinkedinPageModal('add')); document.getElementById('linkedinPageModalSaveBtn').addEventListener('click', async () => { const mode = document.getElementById('linkedinPageModalMode').value; const name = document.getElementById('linkedinPageName').value.trim(); const type = document.getElementById('linkedinPageType').value; const errEl = document.getElementById('linkedinPageModalError'); errEl.classList.add('d-none'); if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; } const ok = await apiPost({ action: mode === 'add' ? 'add_linkedin_page' : 'save_linkedin_page', name, data: { label: document.getElementById('linkedinPageLabel').value.trim(), type, organization_id: type === 'organization' ? document.getElementById('linkedinPageOrgId').value.trim() : '', }}, errEl); if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).hide(); showSuccess('Page saved'); loadConfig(); } }); // ── Compose (Mastodon / LinkedIn direct post) ───────────────────────────────── function openComposeModal(type, name, label) { document.getElementById('composeModalTitle').textContent = 'Post to ' + label; document.getElementById('composeModalType').value = type; document.getElementById('composeModalName').value = name; document.getElementById('composeText').value = ''; document.getElementById('composeModalError').classList.add('d-none'); document.getElementById('composeHint').textContent = type === 'mastodon' ? 'Plain text post — no image upload from this composer.' : 'Text post to your LinkedIn profile.'; bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).show(); setTimeout(() => document.getElementById('composeText').focus(), 300); } document.getElementById('composeModalPostBtn').addEventListener('click', async () => { const type = document.getElementById('composeModalType').value; const name = document.getElementById('composeModalName').value; const text = document.getElementById('composeText').value.trim(); const errEl = document.getElementById('composeModalError'); errEl.classList.add('d-none'); if (!text) { errEl.textContent = 'Text is required'; errEl.classList.remove('d-none'); return; } const body = { project_id: +pid, action: type === 'mastodon' ? 'mastodon_post' : 'linkedin_post', text }; if (type === 'mastodon') body.account_name = name; else body.page_name = name; const btn = document.getElementById('composeModalPostBtn'); btn.disabled = true; const r = await fetch('/api/botcompose', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); btn.disabled = false; if (d.ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).hide(); showSuccess('Posted!'); } else { errEl.textContent = d.error || 'Post failed'; errEl.classList.remove('d-none'); } }); // ── Shared helpers ──────────────────────────────────────────────────────────── async function apiPost(body, errEl) { const r = await fetch('/api/botconfig', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, ...body }), }); const d = await r.json(); if (d.error && errEl) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return false; } if (d.error) { showError(d.error); return false; } return true; } function deleteItem(action, name, confirmMsg, successMsg) { confirmAction(confirmMsg, async () => { const ok = await apiPost({ action, name }, null); if (ok) { showSuccess(successMsg); loadConfig(); } }); } loadConfig(); })(); // ── Bot: schedule editor ───────────────────────────────────────────────────── (function () { const panel = document.getElementById('botSchedulePanel'); if (!panel) return; const pid = panel.dataset.projectId; let _targets = { discord: [], mastodon: [], linkedin: [] }; async function loadTargets() { const r = await fetch('/api/post_schedule?action=targets&project_id=' + pid); const d = await r.json(); if (d.error) { showError(d.error); return; } _targets = d; renderPicker('Discord', 'schedDiscordChannels', 'schedDiscordEmpty', d.discord, 'discord'); renderPicker('Mastodon', 'schedMastodonAccounts', 'schedMastodonEmpty', d.mastodon, 'mastodon'); renderPicker('LinkedIn', 'schedLinkedinProfiles', 'schedLinkedinEmpty', d.linkedin, 'linkedin'); } function renderPicker(name, containerId, emptyId, items, platform) { const container = document.getElementById(containerId); const emptyEl = document.getElementById(emptyId); if (!container) return; if (!items.length) { container.innerHTML = ''; emptyEl?.classList.remove('d-none'); return; } emptyEl?.classList.add('d-none'); container.innerHTML = items.map(item => `
    `).join(''); } function togglePicker(checkboxId, pickerId) { const cb = document.getElementById(checkboxId); const pk = document.getElementById(pickerId); if (cb && pk) cb.addEventListener('change', () => pk.classList.toggle('d-none', !cb.checked)); } togglePicker('schedUseDiscord', 'schedDiscordPicker'); togglePicker('schedUseMastodon', 'schedMastodonPicker'); togglePicker('schedUseLinkedin', 'schedLinkedinPicker'); document.getElementById('schedSubmitBtn').addEventListener('click', async () => { const content = document.getElementById('schedContent').value.trim(); const url = document.getElementById('schedUrl').value.trim(); const at = document.getElementById('schedAt').value; const errEl = document.getElementById('schedError'); errEl.classList.add('d-none'); if (!content) { errEl.textContent = 'Content is required'; errEl.classList.remove('d-none'); return; } if (!at) { errEl.textContent = 'Scheduled time is required'; errEl.classList.remove('d-none'); return; } const targets = []; panel.querySelectorAll('.sched-target-check:checked').forEach(cb => { targets.push({ platform: cb.dataset.platform, target: cb.dataset.target }); }); if (!targets.length) { errEl.textContent = 'Select at least one platform target'; errEl.classList.remove('d-none'); return; } const r = await fetch('/api/post_schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action: 'create', content, url, scheduled_at: at, targets }), }); const d = await r.json(); if (d.error) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return; } showSuccess('Post scheduled'); document.getElementById('schedContent').value = ''; document.getElementById('schedUrl').value = ''; document.getElementById('schedAt').value = ''; panel.querySelectorAll('.sched-target-check').forEach(cb => cb.checked = false); ['schedDiscordPicker', 'schedMastodonPicker', 'schedLinkedinPicker'].forEach(id => document.getElementById(id)?.classList.add('d-none')); ['schedUseDiscord', 'schedUseMastodon', 'schedUseLinkedin'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; }); loadPosts(); }); async function loadPosts() { const r = await fetch('/api/post_schedule?project_id=' + pid); const d = await r.json(); const list = document.getElementById('schedPostsList'); if (d.error) { list.innerHTML = `
    ${esc(d.error)}
    `; return; } if (!d.posts.length) { list.innerHTML = '
    No scheduled posts yet.
    '; return; } const statusBadge = s => { const map = { pending: 'bg-warning text-dark', processing: 'bg-info text-dark', done: 'bg-success', partial: 'bg-danger', sent: 'bg-success', failed: 'bg-danger' }; return `${esc(s)}`; }; const platformIcon = p => ({ discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' }[p] || 'bi-dot'); list.innerHTML = d.posts.map(post => { const targets = (post.targets || []).map(t => `
    ${esc(t.target)} ${statusBadge(t.status)} ${t.error ? `` : ''} ${t.status === 'failed' ? `` : ''}
    `).join(''); return `
    ${esc(post.content.substring(0, 120))}${post.content.length > 120 ? '…' : ''}
    ${post.url ? `${esc(post.url)}` : ''}
    ${statusBadge(post.status)} ${esc(post.scheduled_at)}
    ${targets}
    `; }).join(''); list.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', () => { confirmAction('Delete this scheduled post?', async () => { const r = await fetch('/api/post_schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action: 'delete', id: +b.dataset.postId }), }); const d = await r.json(); if (d.ok) { showSuccess('Post deleted'); loadPosts(); } else showError(d.error || 'Delete failed'); }); })); list.querySelectorAll('.retry-target-btn').forEach(b => b.addEventListener('click', async () => { const r = await fetch('/api/post_schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action: 'retry_target', target_id: +b.dataset.targetId }), }); const d = await r.json(); if (d.ok) { showSuccess('Target queued for retry'); loadPosts(); } else showError(d.error || 'Retry failed'); })); } document.getElementById('schedRunNowBtn')?.addEventListener('click', async () => { const btn = document.getElementById('schedRunNowBtn'); const outBox = document.getElementById('schedRunNowOutput'); const pre = document.getElementById('schedRunNowPre'); btn.disabled = true; outBox.classList.add('d-none'); const r = await fetch('/api/post_schedule', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ project_id: +pid, action: 'run_now' }), }); const d = await r.json(); btn.disabled = false; if (d.error) { showError(d.error); return; } pre.textContent = d.output || '(no output — nothing due)'; outBox.classList.remove('d-none'); loadPosts(); }); loadTargets(); loadPosts(); })(); // ── Bot: log viewer ─────────────────────────────────────────────────────────── (function () { const panel = document.getElementById('botLogsPanel'); if (!panel) return; const pid = panel.dataset.projectId; const pre = document.getElementById('logsOutput'); let _timer = null; async function loadLogs() { const lines = document.getElementById('logsLineCount').value; try { const r = await fetch(`/api/botlogs?project_id=${pid}&lines=${lines}`); const d = await r.json(); if (d.error) { pre.textContent = d.error; return; } pre.textContent = d.lines.join('\n') || '(no log entries)'; pre.scrollTop = pre.scrollHeight; } catch (e) { pre.textContent = 'Error: ' + e.message; } } function startAutoRefresh() { stopAutoRefresh(); _timer = setInterval(loadLogs, 5000); } function stopAutoRefresh() { if (_timer) { clearInterval(_timer); _timer = null; } } document.getElementById('logsRefreshBtn').addEventListener('click', loadLogs); document.getElementById('logsLineCount').addEventListener('change', loadLogs); document.getElementById('logsAutoRefresh').addEventListener('change', e => { e.target.checked ? startAutoRefresh() : stopAutoRefresh(); }); loadLogs(); })(); // ── Social Scheduler ───────────────────────────────────────────────────────── (function () { if (!document.getElementById('socialPage')) return; let _targets = []; let _filter = 'pending'; const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); const CHAR_LIMIT = 500; const AUTOSAVE_DELAY = 2000; function localDTStr(d) { const p = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`; } function defaultScheduleTime() { const d = new Date(Date.now() + 5 * 60 * 1000); d.setSeconds(0, 0); return localDTStr(d); } // ts is stored UTC ("2026-06-04 17:51:00") — parse with Z so JS treats it as UTC function utcToDate(ts) { return new Date(ts.replace(' ', 'T') + 'Z'); } // Convert stored UTC string → "YYYY-MM-DDTHH:MM" in browser local time (for datetime-local input) function utcToLocalInput(ts) { return localDTStr(utcToDate(ts)); } // Convert datetime-local value (local time) → "YYYY-MM-DD HH:MM:00" UTC for the server function localInputToUTC(dtLocal) { const d = new Date(dtLocal); // browser parses datetime-local as local time const p = n => String(n).padStart(2, '0'); return `${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:00`; } function relTime(ts) { const diff = (utcToDate(ts) - Date.now()) / 1000; const abs = Math.abs(diff); const suf = diff > 0 ? '' : ' ago'; const pre = diff > 0 ? 'in ' : ''; if (abs < 60) return 'just now'; if (abs < 3600) return pre + Math.round(abs / 60) + ' min' + suf; if (abs < 86400) return pre + Math.round(abs / 3600) + ' h' + suf; return pre + Math.round(abs / 86400) + ' d' + suf; } function fmtDT(ts) { return localDTStr(utcToDate(ts)).replace('T', ' '); } // ── Emoji picker ───────────────────────────────────────────────────────────── const emojiPanel = document.getElementById('socialEmojiPanel'); let _emojiTA = null; document.querySelectorAll('#socialEmojiPanel .social-emoji-grid > div:not(.social-emoji-cat)').forEach(row => { row.innerHTML = row.textContent.trim().split(/\s+/).map(e => `${e}`).join(' '); }); emojiPanel.querySelector('.social-emoji-grid').addEventListener('click', e => { const ch = e.target.closest('.emoji-btn')?.dataset.e; if (!ch || !_emojiTA) return; const ta = _emojiTA, s = ta.selectionStart, end = ta.selectionEnd; ta.value = ta.value.slice(0, s) + ch + ta.value.slice(end); ta.selectionStart = ta.selectionEnd = s + ch.length; ta.dispatchEvent(new Event('input')); emojiPanel.classList.add('d-none'); ta.focus(); }); document.addEventListener('click', e => { if (!emojiPanel.classList.contains('d-none') && !e.target.closest('#socialEmojiPanel') && !e.target.closest('.sc-emoji-btn')) { emojiPanel.classList.add('d-none'); } }); function showEmojiPanel(btn, ta) { if (!emojiPanel.classList.contains('d-none') && _emojiTA === ta) { emojiPanel.classList.add('d-none'); return; } _emojiTA = ta; const r = btn.getBoundingClientRect(); emojiPanel.style.top = (r.bottom + 4) + 'px'; emojiPanel.style.left = Math.min(r.left, window.innerWidth - 280) + 'px'; emojiPanel.classList.remove('d-none'); } // ── Targets ────────────────────────────────────────────────────────────────── async function loadTargets() { const r = await fetch('/api/social?action=targets'); const d = await r.json(); _targets = d.groups || []; } function renderTargetsInto(container, prefill, hasImage) { if (!_targets.length) { container.innerHTML = '
    No platforms configured. Settings →
    '; return; } const pills = _targets.flatMap(group => ['discord', 'mastodon', 'linkedin'].flatMap(platform => (group.targets[platform] || []).map(t => { const active = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key); const imgOn = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image); return ``; }) ) ); container.innerHTML = `
    ${pills.join('')}
    `; container.querySelectorAll('.social-pill').forEach(pill => { pill.addEventListener('click', e => { if (e.target.closest('.social-pill-img')) { if (pill.classList.contains('active')) pill.classList.toggle('img-on'); return; } pill.classList.toggle('active'); const imgVisible = !container.querySelector('.social-pill-img.d-none'); if (pill.classList.contains('active') && imgVisible) pill.classList.add('img-on'); else if (!pill.classList.contains('active')) pill.classList.remove('img-on'); }); }); } function collectTargetsFrom(container) { const out = []; container.querySelectorAll('.social-pill.active').forEach(pill => { out.push({ project_id: +pill.dataset.pid, platform: pill.dataset.platform, target_key: pill.dataset.key, include_image: pill.classList.contains('img-on') ? 1 : 0, }); }); return out; } function toggleImageInCard(card, show) { card.querySelectorAll('.social-pill-img').forEach(el => el.classList.toggle('d-none', !show)); if (show) card.querySelectorAll('.social-pill.active').forEach(p => p.classList.add('img-on')); if (!show) card.querySelectorAll('.social-pill').forEach(p => p.classList.remove('img-on')); } // ── Shared image upload ─────────────────────────────────────────────────────── const imgInput = document.getElementById('socialImageInput'); let _uploadCard = null; imgInput.addEventListener('change', async function () { const card = _uploadCard; if (!this.files[0] || !card) return; const fd = new FormData(); fd.append('image', this.files[0]); this.value = ''; const r = await fetch('/api/social_upload', { method: 'POST', body: fd }); const d = await r.json(); if (d.error) { showCardError(card, d.error); return; } card.querySelector('.sc-img-path').value = d.path; card.querySelector('.sc-img-thumb').src = d.url; card.querySelector('.sc-img-preview').classList.remove('d-none'); card.querySelector('.sc-remove-img-btn').classList.remove('d-none'); toggleImageInCard(card, true); scheduleAutosave(card); }); // ── Status badge ───────────────────────────────────────────────────────────── const STATUS_BADGE = s => { const m = { pending: ['warning text-dark','clock'], sent: ['success','check-circle'], done: ['success','check-circle-fill'], failed: ['danger','x-circle'], partial: ['warning text-dark','exclamation-circle'], processing: ['info text-dark','arrow-repeat'] }; const [cls, icon] = m[s] || ['secondary','dot']; return `${esc(s)}`; }; // ── Build view pane HTML ────────────────────────────────────────────────────── function buildViewPane(post) { const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' }; const canRetry = post.targets?.some(t => t.status === 'failed'); const platBadges = (post.targets || []).map(t => { const sCls = { sent: 'bg-success', done: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' }; return ``; }).join(' '); return `
    ${esc(fmtDT(post.scheduled_at))} (${relTime(post.scheduled_at)})
    ${STATUS_BADGE(post.status)}
    ${canRetry ? `` : ''}
    ${esc(post.content)}
    ${post.image_url ? `
    ` : ''}
    ${platBadges}
    `; } // ── Build edit pane HTML ────────────────────────────────────────────────────── function buildEditPane(post) { const isNew = !post; const dt = post ? utcToLocalInput(post.scheduled_at) : defaultScheduleTime(); const content = post?.content || ''; const imgPath = post?.image_path || ''; const imgUrl = post?.image_url || ''; return `
    ${[...content].length} / ${CHAR_LIMIT}
    Loading…
    ${!isNew ? '' : ''}
    `; } // ── Autosave ────────────────────────────────────────────────────────────────── function setSaveIndicator(card, state) { const el = card.querySelector('.sc-save-indicator'); if (!el) return; const map = { dirty: ['●', 'text-muted'], saving: ['saving…', 'text-info small'], saved: ['✓ saved', 'text-success small'], error: ['✗ error', 'text-danger small'] }; const [txt, cls] = map[state] || ['', '']; el.textContent = txt; el.className = 'sc-save-indicator ' + cls; if (state === 'saved') setTimeout(() => { if (el.textContent === '✓ saved') el.textContent = ''; }, 2000); } function scheduleAutosave(card) { if (!card._post) return; // don't autosave new post card setSaveIndicator(card, 'dirty'); clearTimeout(card._autosaveTimer); card._autosaveTimer = setTimeout(() => doAutosave(card), AUTOSAVE_DELAY); } async function doAutosave(card) { const post = card._post; if (!post) return; const content = card.querySelector('.sc-content').value.trim(); const scheduledAt = card.querySelector('.sc-dt').value; const imagePath = card.querySelector('.sc-img-path').value; const targets = collectTargetsFrom(card.querySelector('.sc-targets')); if (!content || !scheduledAt || !targets.length) return; setSaveIndicator(card, 'saving'); const scheduledUTC = localInputToUTC(scheduledAt); const body = { action: 'update', id: post.id, content, scheduled_at: scheduledUTC, image_path: imagePath, targets }; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); } else { setSaveIndicator(card, 'saved'); card._post = { ...post, content, scheduled_at: scheduledUTC, image_path: imagePath }; } } // ── Init edit card ──────────────────────────────────────────────────────────── function initEditCard(card, post) { card._post = post || null; const textarea = card.querySelector('.sc-content'); const charCount = card.querySelector('.sc-char-count'); textarea.addEventListener('input', () => { const n = [...textarea.value].length; charCount.textContent = `${n} / ${CHAR_LIMIT}`; charCount.className = 'small ' + (n > CHAR_LIMIT ? 'social-char-over' : n > CHAR_LIMIT * 0.9 ? 'social-char-warn' : 'text-muted'); scheduleAutosave(card); }); card.querySelector('.sc-dt').addEventListener('change', () => scheduleAutosave(card)); card.querySelector('.sc-emoji-btn').addEventListener('click', e => { e.stopPropagation(); showEmojiPanel(e.currentTarget, textarea); }); card.querySelector('.sc-upload-btn').addEventListener('click', () => { _uploadCard = card; imgInput.click(); }); card.querySelector('.sc-remove-img-btn').addEventListener('click', () => { card.querySelector('.sc-img-path').value = ''; card.querySelector('.sc-img-thumb').src = ''; card.querySelector('.sc-img-preview').classList.add('d-none'); card.querySelector('.sc-remove-img-btn').classList.add('d-none'); toggleImageInCard(card, false); scheduleAutosave(card); }); card.querySelector('.sc-save-btn').addEventListener('click', () => submitCard(card, post)); card.querySelector('.sc-cancel-btn')?.addEventListener('click', () => { clearTimeout(card._autosaveTimer); exitEditMode(card); }); const targetsDiv = card.querySelector('.sc-targets'); renderTargetsInto(targetsDiv, post?.targets || null, !!card.querySelector('.sc-img-path').value); // wire target pill changes to autosave targetsDiv.addEventListener('click', () => scheduleAutosave(card)); } // ── Init view card ──────────────────────────────────────────────────────────── function initViewCard(card, post) { card._post = post; card.querySelector('.sc-view-pane').addEventListener('click', e => { if (e.target.closest('button')) return; enterEditMode(card, post); }); card.querySelector('.sc-edit-btn').addEventListener('click', () => enterEditMode(card, post)); card.querySelector('.sc-dup-btn').addEventListener('click', () => duplicatePost(post.id)); card.querySelector('.sc-retry-btn')?.addEventListener('click', async () => { const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'retry', id: post.id }) }); const d = await r.json(); if (d.ok) loadPosts(); }); const delBtn = card.querySelector('.sc-del-btn'); delBtn.addEventListener('click', async () => { if (!delBtn._confirm) { delBtn._confirm = true; delBtn.innerHTML = 'Sure?'; delBtn.classList.replace('btn-outline-danger', 'btn-danger'); setTimeout(() => { if (delBtn._confirm) { delBtn._confirm = false; delBtn.innerHTML = ''; delBtn.classList.replace('btn-danger', 'btn-outline-danger'); } }, 3000); return; } delBtn._confirm = false; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: post.id }) }); const d = await r.json(); if (d.ok) card.closest('[class*="col-"]').remove(); else showCardError(card, d.error); }); } // ── Enter / exit edit mode ──────────────────────────────────────────────────── function enterEditMode(card, post) { card._post = post; card.classList.add('sc-editing'); card.innerHTML = buildEditPane(post); initEditCard(card, post); constrainImages(); } function exitEditMode(card) { const post = card._post; card.classList.remove('sc-editing'); card.innerHTML = buildViewPane(post); initViewCard(card, post); constrainImages(); } // ── Submit (explicit save) ──────────────────────────────────────────────────── async function submitCard(card, post) { const content = card.querySelector('.sc-content').value.trim(); const scheduledAt = card.querySelector('.sc-dt').value; const imagePath = card.querySelector('.sc-img-path').value; const targets = collectTargetsFrom(card.querySelector('.sc-targets')); const isNew = !post; hideCardError(card); if (!content) { showCardError(card, 'Content is required'); return; } if (!scheduledAt) { showCardError(card, 'Scheduled time is required'); return; } if (!targets.length) { showCardError(card, 'Select at least one platform target'); return; } clearTimeout(card._autosaveTimer); setSaveIndicator(card, 'saving'); const body = { action: isNew ? 'create' : 'update', content, scheduled_at: localInputToUTC(scheduledAt), image_path: imagePath, targets }; if (!isNew) body.id = post.id; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const d = await r.json(); if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); return; } if (isNew) { // reset new-post card card.innerHTML = buildEditPane(null); initEditCard(card, null); } loadPosts(); } // ── Duplicate ───────────────────────────────────────────────────────────────── async function duplicatePost(id) { const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'duplicate', id }) }); const d = await r.json(); if (d.error) { return; } const newCard = document.querySelector('.sc-new-card'); if (!newCard) return; newCard.innerHTML = buildEditPane({ ...d.post, scheduled_at: '', targets: d.targets || [] }); newCard.querySelector('.sc-dt').value = ''; initEditCard(newCard, null); newCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } // ── Grid render ─────────────────────────────────────────────────────────────── function renderGrid(posts) { const grid = document.getElementById('socialPostsGrid'); grid.innerHTML = ''; // "new post" card — always first const newCol = document.createElement('div'); newCol.className = 'col-xl-4 col-md-6 col-12'; newCol.innerHTML = `
    ${buildEditPane(null)}
    `; grid.appendChild(newCol); initEditCard(newCol.querySelector('.sc-card'), null); if (!posts.length) { const emptyCol = document.createElement('div'); emptyCol.className = 'col-12 d-flex align-items-center text-muted small py-4 ps-3'; emptyCol.innerHTML = `${_filter === 'pending' ? 'No pending posts.' : 'No posts yet.'}`; grid.appendChild(emptyCol); } else { posts.forEach(post => { const col = document.createElement('div'); col.className = 'col-xl-4 col-md-6 col-12'; col.innerHTML = `
    ${buildViewPane(post)}
    `; grid.appendChild(col); initViewCard(col.querySelector('.sc-card'), post); }); } constrainImages(); } function constrainImages() { document.querySelectorAll('.sc-img-wrap').forEach(wrap => { const w = wrap.clientWidth || wrap.parentElement?.clientWidth; if (w) wrap.style.maxHeight = w + 'px'; }); } // ── Load posts ──────────────────────────────────────────────────────────────── async function loadPosts() { const r = await fetch('/api/social?status=' + _filter); const d = await r.json(); const grid = document.getElementById('socialPostsGrid'); if (d.error) { grid.innerHTML = `
    ${esc(d.error)}
    `; return; } renderGrid(d.posts); } // ── Filters ─────────────────────────────────────────────────────────────────── document.getElementById('socialFilterPending').addEventListener('click', function () { _filter = 'pending'; this.classList.add('active'); document.getElementById('socialFilterAll').classList.remove('active'); loadPosts(); }); document.getElementById('socialFilterAll').addEventListener('click', function () { _filter = 'all'; this.classList.add('active'); document.getElementById('socialFilterPending').classList.remove('active'); loadPosts(); }); document.getElementById('socialRefreshBtn').addEventListener('click', loadPosts); document.getElementById('socialDispatchBtn').addEventListener('click', async function () { this.disabled = true; this.innerHTML = 'Running…'; const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'dispatch' }) }); const d = await r.json(); this.disabled = false; this.innerHTML = 'Dispatch now'; const out = d.output?.trim(); const msg = out || 'No pending posts were due (or all sent silently).'; const box = document.getElementById('socialDispatchOut'); box.textContent = msg; box.classList.remove('d-none'); loadPosts(); }); // ── Per-card error helpers ──────────────────────────────────────────────────── function showCardError(card, msg) { const el = card.querySelector('.sc-error'); if (!el) return; el.textContent = msg; el.classList.remove('d-none'); } function hideCardError(card) { card.querySelector('.sc-error')?.classList.add('d-none'); } // Init loadTargets().then(() => loadPosts()); })(); // ── Settings: social eligible projects ────────────────────────────────────── (function () { const list = document.getElementById('socialProjectsList'); if (!list) return; async function loadSocialSettings() { const r = await fetch('/api/social?action=settings'); const d = await r.json(); const enabled = d.enabled || []; if (!d.projects.length) { list.innerHTML = '
    No Discord Bot projects found. Add one via project scan first.
    '; return; } list.innerHTML = d.projects.map(p => `
    `).join(''); } document.getElementById('saveSocialProjectsBtn').addEventListener('click', async () => { const ids = [...list.querySelectorAll('.social-proj-cb:checked')].map(cb => +cb.value); const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save_settings', project_ids: ids }) }); const d = await r.json(); if (d.ok) { const saved = document.getElementById('socialProjectsSaved'); saved.classList.remove('d-none'); setTimeout(() => saved.classList.add('d-none'), 2000); } }); loadSocialSettings(); })();