'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 (_tabFmCMs[id]) { try { _tabFmCMs[id].toTextArea(); } catch (e) {} delete _tabFmCMs[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'; } // FM editor (when visible) fills the same body slot const fmContainer = wrap.querySelector('.ie-mk-fm'); const fmCM = _tabFmCMs[id]; if (fmContainer && fmContainer.style.display !== 'none' && fmCM) { fmContainer.style.height = bodyH + 'px'; try { fmCM.setSize('100%', bodyH + 'px'); } catch (e) {} } } async function _openMdEditorTab(pid, path, name, content, isDraft) { const tabContent = document.getElementById('editorTabContent'); if (!tabContent) return; const id = 'tab' + (++_tabCounter); // Split content into FM + body. FM goes into a separate CodeMirror toggled // via the FM button on mk-mount's toolbar; body goes into Milkdown. On save // they are merged back so the file round-trips byte-identical. const { fm, body, photos } = _parseMdSource(content); const hadFm = content.startsWith('---'); _tabs.push({ id, pid, path, name, kind: 'md-editor', isDraft, dirty: false, hadFm }); _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); // Mount Milkdown over the FULL document (front matter included). The // frontmatter plugin renders the YAML block as code at the top of the // editor — no separate CodeMirror, no merge-on-save dance. Image URLs are // kept as authored; only the rendered .src is rewritten via the // observer below so ProseMirror's model stays clean. const mountEl = div.querySelector('#paneEditor-' + id); const ta = document.createElement('textarea'); ta.className = 'mk-mount'; ta.value = body; // body only — FM is edited separately mountEl.appendChild(ta); ta.onMkInput = () => _markDirty(id); _tabMdMounts[id] = ta; // Recompute editor height on every layout-affecting change. const ro = new ResizeObserver(() => _reflowMdEditor(id)); ro.observe(mountEl); _tabMdReflowers[id] = ro; ta.addEventListener('mk-mounted', () => { _injectFmEditor(id, fm, pid); _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)); // 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); requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id))); } // Refresh the photos banner from the FM CodeMirror's current value. function _refreshPhotosBanner(id, pid) { const fmCM = _tabFmCMs[id]; const banner = document.querySelector('#tc-' + id + ' .md-photos-banner'); if (!banner) return; const fmText = fmCM ? fmCM.getValue() : ''; const { photos } = _parseMdSource('---\n' + fmText + '\n---\n'); _renderPhotosBanner(banner, photos, pid); } // Inject the FM CodeMirror as a sibling of .ie-mk-body (hidden by default) // plus an "FM" toggle button on the mk-mount toolbar. function _injectFmEditor(id, initialFm, pid) { const root = document.getElementById('tc-' + id); if (!root) return; const wrap = root.querySelector('.mk-mount-wrap'); const ieBody = wrap?.querySelector('.ie-mk-body'); const tb = wrap?.querySelector('.ie-mk-toolbar'); if (!wrap || !ieBody || !tb || _tabFmCMs[id]) return; // FM container — hidden by default, slots in alongside .ie-mk-body const fmContainer = document.createElement('div'); fmContainer.className = 'ie-mk-fm'; fmContainer.style.display = 'none'; wrap.insertBefore(fmContainer, ieBody.nextSibling); const fmCM = CodeMirror(fmContainer, { value: initialFm, mode: 'yaml', theme: 'dracula', lineNumbers: true, lineWrapping: true, tabSize: 2, extraKeys: { 'Ctrl-S': () => _paneTabSave(id), 'Cmd-S': () => _paneTabSave(id) }, }); fmCM.on('change', () => { _markDirty(id); _refreshPhotosBanner(id, pid); }); _tabFmCMs[id] = fmCM; // FM toggle on the editor toolbar (next to mk-mount's MD button) const fmBtn = document.createElement('button'); fmBtn.type = 'button'; fmBtn.className = 'btn btn-sm btn-outline-secondary mk-fm-toggle'; fmBtn.title = 'Edit front matter (YAML)'; fmBtn.innerHTML = ' FM'; const mkMode = tb.querySelector('.mk-mode'); if (mkMode) tb.insertBefore(fmBtn, mkMode); else tb.appendChild(fmBtn); fmBtn.addEventListener('click', () => { const showingFm = fmContainer.style.display !== 'none'; if (showingFm) { fmContainer.style.display = 'none'; ieBody.style.display = ''; fmBtn.classList.remove('active'); } else { ieBody.style.display = 'none'; fmContainer.style.display = ''; fmBtn.classList.add('active'); requestAnimationFrame(() => fmCM.refresh()); } _reflowMdEditor(id); }); } // Inject the kebab (Delete + Publish) at the right end of mk-mount's toolbar // — keeps it in the editor's own row instead of squatting between the file // tab strip and the editor canvas. 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)); 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 body = mount.mkMount.getContent() ?? ''; const fmCM = _tabFmCMs[id]; const fm = fmCM ? fmCM.getValue() : ''; const fmTrimmed = fm.replace(/^\n+|\n+$/g, ''); if (tab.hadFm || fmTrimmed) { content = '---\n' + fmTrimmed + '\n---\n\n' + body; } else { content = body; } } 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('draft'); } else { showError(data.error || 'Publish failed'); } }); } // 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); 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'); }); }); } // 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','showDrafts'].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) { currentType = type; window._postsCurrentType = type; setActiveBtn(type === 'page' ? 'showPages' : type === 'draft' ? 'showDrafts' : 'showPosts'); const sq = document.getElementById('searchQuery'); if (sq) sq.value = ''; searchResults.classList.add('d-none'); postsList.classList.remove('d-none'); postsList.innerHTML = '
    Loading…
    '; const res = await fetch(`/api/posts?project_id=${pid}&type=${type}`); const data = await res.json(); if (data.missing_dir) { const dirs = { post: 'source/_posts/', page: 'source/', draft: 'source/_drafts/' }; postsList.innerHTML = `
    ${dirs[type] ?? type} not found.
    `; _renderFilterBar(); return; } const items = _applyFilter(data.items); if (!items.length) { postsList.innerHTML = `
    ${ _activeFilter ? `No ${type}s match this filter.` : `No ${type}s 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')); document.getElementById('showDrafts')?.addEventListener('click', () => { currentType = 'draft'; window._postsCurrentType = 'draft'; setActiveBtn('showDrafts'); const sq = document.getElementById('searchQuery'); if (sq) sq.value = ''; searchResults.classList.add('d-none'); postsList.classList.remove('d-none'); loadDraftsList(); }); document.getElementById('newItemBtn')?.addEventListener('click', () => { createNewDraft(); }); async function loadDraftsList() { postsList.innerHTML = '
    Loading…
    '; const res = await fetch(`/api/drafts?project_id=${pid}`); const data = await res.json(); if (!data.drafts?.length) { postsList.innerHTML = '
    No drafts yet.
    '; return; } postsList.innerHTML = data.drafts.map(d => `
    ${esc(d.title || 'Untitled')}
    ${esc((d.updated_at ?? '').substring(0,10))} ${d.folder ? ' • ' + esc(d.folder) + '' : ''}
    `).join(''); postsList.querySelectorAll('.draft-list-row').forEach(row => { row.addEventListener('click', ev => { if (ev.target.closest('button')) return; const d = data.drafts.find(x => x.id === parseInt(row.dataset.id)); if (d) openDraftInTab(d); }); }); postsList.querySelectorAll('.btn-del-draft').forEach(btn => { btn.addEventListener('click', () => confirmAction('Delete this draft?', async () => { const r = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id), project_id: parseInt(pid) }), }); const d = await r.json(); if (d.ok) { const tab = _tabs.find(t => t.kind === 'draft' && t.draftId === parseInt(btn.dataset.id)); if (tab) _closeTab(tab.id); loadDraftsList(); } else showError(d.error || 'Error'); })); }); postsList.querySelectorAll('.btn-publish-draft').forEach(btn => { btn.addEventListener('click', async () => { const r = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'publish', id: parseInt(btn.dataset.id), project_id: parseInt(pid) }), }); const d = await r.json(); if (d.ok) { showSuccess('Published as ' + (d.path || d.filename || 'post')); const tab = _tabs.find(t => t.kind === 'draft' && t.draftId === parseInt(btn.dataset.id)); if (tab) _closeTab(tab.id); loadDraftsList(); } else showError(d.error || 'Error'); }); }); } async function createNewDraft() { const res = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'create', project_id: parseInt(pid), title: 'New draft' }), }); const data = await res.json(); if (!data.ok) { showError(data.error || 'Error'); return; } await loadDraftsList(); const r2 = await fetch(`/api/drafts?project_id=${pid}`); const d2 = await r2.json(); const newD = d2.drafts?.find(x => x.id === data.id); if (newD) openDraftInTab(newD); } function openDraftInTab(draft) { const existing = _tabs.find(t => t.kind === 'draft' && t.draftId === draft.id); if (existing) { _switchTab(existing.id); return; } const id = 'tab' + (++_tabCounter); _tabs.push({ id, pid, path: null, name: draft.title || 'Untitled', kind: 'draft', draftId: draft.id, dirty: false, }); _renderTabBar(); const div = document.createElement('div'); div.id = 'tc-' + id; div.style.height = '100%'; div.style.display = 'none'; div.style.position = 'relative'; div.innerHTML = `
    _posts/
    `; document.getElementById('editorTabContent').appendChild(div); // Auto-slug from title (kept from original) div.querySelector('#dtTitle-' + id)?.addEventListener('input', (e) => { const slug = e.target.value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); const slugEl = div.querySelector('#dtSlug-' + id); if (slugEl && !slugEl.dataset.userEdited) slugEl.value = slug; _markDirty(id); }); ['#dtSlug-', '#dtFolder-'].forEach(prefix => { div.querySelector(prefix + id)?.addEventListener('input', function () { this.dataset.userEdited = '1'; _markDirty(id); }); }); // Milkdown body const mountEl = div.querySelector('#paneEditor-' + id); const ta = document.createElement('textarea'); ta.className = 'mk-mount'; ta.value = draft.body || ''; 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', () => { _injectDraftKebab(id, pid, draft.id); _reflowMdEditor(id); }); ta.addEventListener('mk-ready', () => { _reflowMdEditor(id); _attachImgSrcRewriter(id, pid); }); div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => saveDraftTab(id)); div.addEventListener('keydown', (e) => { if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); saveDraftTab(id); } }); _switchTab(id); requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id))); } // Kebab in mk-mount's toolbar — Publish + Delete for drafts. function _injectDraftKebab(id, pid, draftId) { const root = document.getElementById('tc-' + id); const tb = root?.querySelector('.ie-mk-toolbar'); if (!tb || tb.querySelector('.mk-pane-kebab')) return; const wrap = document.createElement('div'); wrap.className = 'dropdown mk-pane-kebab ms-1'; wrap.innerHTML = ` `; tb.appendChild(wrap); wrap.querySelector('#dtPublishBtn-' + id).addEventListener('click', () => publishDraftTab(id)); wrap.querySelector('#dtDeleteBtn-' + id).addEventListener('click', () => { confirmAction('Delete this draft?', async () => { const r = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'delete', id: draftId, project_id: parseInt(pid) }), }); const d = await r.json(); if (d.ok) { showSuccess('Deleted'); _closeTab(id); loadDraftsList(); } else showError(d.error || 'Error'); }); }); } async function saveDraftTab(id) { const tab = _tabs.find(t => t.id === id); if (!tab || tab.kind !== 'draft') return; const mount = _tabMdMounts[id]; if (!mount?.mkMount) { showError('Editor not ready'); return; } const title = document.getElementById('dtTitle-' + id)?.value.trim() || ''; const slug = document.getElementById('dtSlug-' + id)?.value.trim() || ''; const folder = document.getElementById('dtFolder-' + id)?.value.trim() || ''; const body = mount.mkMount.getContent() || ''; const res = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'update', id: tab.draftId, project_id: parseInt(pid), title, slug, folder, body }), }); const data = await res.json(); if (data.ok) { tab.dirty = false; tab.name = title || 'Untitled'; showSuccess('Draft saved'); _renderTabBar(); _updateSaveBtnState(id); loadDraftsList(); } else { showError(data.error || 'Save failed'); } } async function publishDraftTab(id) { const tab = _tabs.find(t => t.id === id); if (!tab || tab.kind !== 'draft') return; await saveDraftTab(id); confirmAction('Publish draft to _posts?', async () => { const res = await fetch('/api/drafts', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ action: 'publish', id: tab.draftId, project_id: parseInt(pid) }), }); const data = await res.json(); if (data.ok) { showSuccess('Published: ' + data.filename); _closeTab(id); loadDraftsList(); _postsLoadFn('post'); } else { showError(data.error || 'Publish failed'); } }); } 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', 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', 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); });