HackmanCMS/web/assets/js/app.js
Bashy 09e3406847 Support personal profile LinkedIn posting
- OAuth callback fetches /v2/userinfo to store member_id + name,
  auto-creates a 'profile' personal page on first connect
- Page modal adds type selector (personal/org), hides org ID for personal
- renderLinkedinPages shows 'Personal' badge instead of org ID
- botconfig saves type field; personal pages omit organization_id

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 13:25:19 +03:00

3584 lines
163 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 = `<div class="d-flex"><div class="toast-body small">${esc(msg)}</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
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 = `<div class="modal-dialog modal-sm"><div class="modal-content">
<div class="modal-body small" id="_confirmMsg"></div>
<div class="modal-footer py-2">
<button class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button class="btn btn-sm btn-danger" id="_confirmOk">Confirm</button>
</div></div></div>`;
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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 = `
<div id="paneCm-${id}" class="pane-cm-container" style="height:100%"></div>
<button class="md-pane-save d-none" id="paneSaveBtn-${id}" title="Save (Ctrl+S)">
<i class="bi bi-floppy me-1"></i>Save
</button>`;
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 `<div class="file-preview-pane"><img src="${esc(src)}" alt="${esc(name)}"><p class="file-preview-meta">${esc(name)}</p></div>`;
if (mk === 'pdf') return `<div class="file-preview-pane"><embed src="${esc(src)}" type="application/pdf"><p class="file-preview-meta">${esc(name)}</p></div>`;
if (mk === 'video') return `<div class="file-preview-pane"><video controls style="max-width:100%;max-height:calc(100vh - 380px)"><source src="${esc(src)}"></video><p class="file-preview-meta">${esc(name)}</p></div>`;
if (mk === 'audio') return `<div class="file-preview-pane"><audio controls class="mt-4"><source src="${esc(src)}"></audio><p class="file-preview-meta">${esc(name)}</p></div>`;
return `<div class="file-preview-pane"><p class="text-muted">${esc(name)}</p></div>`;
}
function _showGalleryOverlay(src, name) {
let ov = document.getElementById('_galleryOverlay');
if (!ov) {
ov = document.createElement('div');
ov.id = '_galleryOverlay';
ov.className = 'gallery-overlay';
ov.innerHTML = `<img id="_galleryImg" src=""><p class="gallery-caption" id="_galleryCaption"></p>`;
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) ? '<span class="tab-dirty">●</span>' : '';
const icon = t.kind === 'preview' ? '<i class="bi bi-image me-1 opacity-50"></i>' : '';
return `<div class="editor-tab ${t.id === _activeTabId ? 'active' : ''}" data-tab-id="${t.id}">
${icon}<span class="tab-name">${esc(t.name)}</span>${dirty}
<span class="tab-close" data-close-tab="${t.id}">✕</span>
</div>`;
}).join('');
bar.querySelectorAll('.editor-tab').forEach(el => {
el.addEventListener('click', ev => {
if (ev.target.closest('[data-close-tab]')) return;
_switchTab(el.dataset.tabId);
});
});
bar.querySelectorAll('[data-close-tab]').forEach(el => {
el.addEventListener('click', () => _closeTab(el.dataset.closeTab));
});
_saveTabState();
}
function _tabStorageKey() {
const fb = document.getElementById('fileBrowser');
const pp = document.getElementById('postsPanel');
const el = fb || pp;
if (!el) return null;
return 'hackmancms_tabs_' + el.dataset.projectId + '_' + (fb ? 'files' : 'posts');
}
function _saveTabState() {
const key = _tabStorageKey();
if (!key) return;
const items = _tabs
.filter(t => t.path && (t.kind === 'editor' || t.kind === 'preview'))
.map(t => ({ pid: t.pid, path: t.path, name: t.name, kind: t.kind }));
if (!items.length) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify({ tabs: items, active: _activeTabId }));
}
function _restoreTabState() {
const key = _tabStorageKey();
if (!key) return;
let saved;
try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { saved = null; }
if (!saved?.tabs?.length) return;
saved.tabs.forEach(t => openFileEditor(t.pid, t.path, t.name));
}
function _switchTab(id) {
_tabs.forEach(t => {
const div = document.getElementById('tc-' + t.id);
if (div) div.style.display = 'none';
});
const div = document.getElementById('tc-' + id);
if (div) {
div.style.display = 'flex';
div.style.flexDirection = 'column';
div.style.height = '100%';
requestAnimationFrame(() => _tabCMs[id]?.refresh());
}
_activeTabId = id;
_renderTabBar();
// Hide placeholder
const content = document.getElementById('editorTabContent');
const placeholder = content?.querySelector('.editor-placeholder');
if (placeholder) placeholder.style.display = 'none';
}
function _closeTab(id) {
const idx = _tabs.findIndex(t => t.id === id);
if (idx === -1) return;
if (_tabCMs[id]) { try { _tabCMs[id].toTextArea(); } catch(e) {} delete _tabCMs[id]; }
delete _tabMdMounts[id];
if (_tabMdReflowers[id]) {
try { _tabMdReflowers[id].disconnect(); } catch (e) {}
delete _tabMdReflowers[id];
}
if (_tabImgObservers[id]) {
try { _tabImgObservers[id].disconnect(); } catch (e) {}
delete _tabImgObservers[id];
}
document.getElementById('tc-' + id)?.remove();
_tabs.splice(idx, 1);
if (_activeTabId === id) {
const next = _tabs[Math.min(idx, _tabs.length - 1)];
if (next) { _switchTab(next.id); }
else {
_activeTabId = null;
const content = document.getElementById('editorTabContent');
if (content) {
const ph = content.querySelector('.editor-placeholder');
if (ph) ph.style.display = '';
else content.innerHTML = `<div class="editor-placeholder"><i class="bi bi-cursor-text fs-1 d-block mb-2 opacity-25"></i>Select a file to edit</div>`;
}
}
}
_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 <img>'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 <img> elements (paste, image insert,
// ProseMirror re-render). Filtering attributeFilter to `src` avoids loops
// when our own setAttribute fires a notification.
const obs = new MutationObserver(() => _rewriteImgsInPlace(pm, pid));
obs.observe(pm, { childList: true, subtree: true,
attributes: true, attributeFilter: ['src'] });
// Replace any existing observer (e.g. on re-mount)
if (_tabImgObservers[id]) try { _tabImgObservers[id].disconnect(); } catch (e) {}
_tabImgObservers[id] = obs;
}
function _reflowMdEditor(id) {
const mount = document.getElementById('paneEditor-' + id);
if (!mount) return;
const stackH = mount.clientHeight;
if (stackH < 1) return;
// Editor mount may not be present yet (mk-mount wraps the textarea async).
const wrap = mount.querySelector('.mk-mount-wrap');
if (!wrap) return;
const body = wrap.querySelector('.ie-mk-body');
if (!body) return;
const wrapToolbar = wrap.querySelector('.ie-mk-toolbar');
const innerPhotos = body.querySelector('.md-photos-banner');
const toolbarH = wrapToolbar ? wrapToolbar.offsetHeight : 40;
const photosH = innerPhotos && !innerPhotos.classList.contains('d-none')
? innerPhotos.offsetHeight : 0;
const wrapBd = 2;
// Lock wrap and body to definite pixel heights — ProseMirror's percentage
// min-height doesn't cascade reliably through Milkdown's wrappers, so we
// size them manually to make body's overflow-y: auto fire dependably.
wrap.style.flex = '0 0 auto';
wrap.style.height = stackH + 'px';
const bodyH = Math.max(80, stackH - toolbarH - wrapBd);
body.style.flex = '0 0 auto';
body.style.height = bodyH + 'px';
const editorMinH = Math.max(60, bodyH - photosH);
const milkdownRoot = body.querySelector(':scope > div:not(.md-photos-banner)');
if (milkdownRoot) milkdownRoot.style.minHeight = editorMinH + 'px';
body.querySelectorAll('.milkdown, .editor, .ProseMirror').forEach(el => {
el.style.minHeight = editorMinH + 'px';
});
const taSrc = body.querySelector('textarea.ie-mk-ta');
if (taSrc) {
taSrc.style.minHeight = editorMinH + 'px';
taSrc.style.height = editorMinH + 'px';
}
}
async function _openMdEditorTab(pid, path, name, content, isDraft) {
const tabContent = document.getElementById('editorTabContent');
if (!tabContent) return;
const id = 'tab' + (++_tabCounter);
const { fm, body, photos } = _parseMdSource(content);
const hadFm = content.startsWith('---');
const isHexo = path.startsWith('source/_posts/') || path.startsWith('source/_drafts/');
const useFmBlock = hadFm || isHexo;
_tabs.push({ id, pid, path, name, kind: 'md-editor', isDraft, dirty: false });
_renderTabBar();
const div = document.createElement('div');
div.id = 'tc-' + id;
div.style.display = 'none';
div.style.height = '100%';
div.style.position = 'relative';
div.innerHTML = `
<div class="md-editor-mount" id="paneEditor-${id}"></div>
<button class="md-pane-save d-none" id="paneSaveBtn-${id}" title="Save (Ctrl+S)">
<i class="bi bi-floppy me-1"></i>Save
</button>`;
tabContent.appendChild(div);
const mountEl = div.querySelector('#paneEditor-' + id);
const ta = document.createElement('textarea');
ta.className = 'mk-mount';
// FM lives as the first fenced yaml code block inside Milkdown.
// On save the block is extracted and serialised back to ---\nfm\n--- form.
ta.value = useFmBlock ? '```yaml\n' + fm + '\n```\n\n' + body : body;
ta.dataset.hasFmBlock = useFmBlock ? '1' : '0';
mountEl.appendChild(ta);
ta.onMkInput = () => _markDirty(id);
_tabMdMounts[id] = ta;
const ro = new ResizeObserver(() => _reflowMdEditor(id));
ro.observe(mountEl);
_tabMdReflowers[id] = ro;
ta.addEventListener('mk-mounted', () => {
_injectMkToolbarExtras(id, pid, path, isDraft);
_reflowMdEditor(id);
});
ta.addEventListener('mk-ready', () => {
const editorBody = div.querySelector('.ie-mk-body');
if (editorBody) {
let banner = editorBody.querySelector('.md-photos-banner');
if (!banner) {
banner = document.createElement('div');
banner.className = 'md-photos-banner inside-editor';
banner.id = 'panePhotos-' + id;
editorBody.insertBefore(banner, editorBody.firstChild);
}
_renderPhotosBanner(banner, photos, pid);
}
_reflowMdEditor(id);
_attachImgSrcRewriter(id, pid);
});
div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id));
div.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
_paneTabSave(id);
}
});
_switchTab(id);
requestAnimationFrame(() => requestAnimationFrame(() => _reflowMdEditor(id)));
}
function _injectMkToolbarExtras(id, pid, path, isDraft) {
const root = document.getElementById('tc-' + id);
if (!root) return;
const tb = root.querySelector('.ie-mk-toolbar');
if (!tb || tb.querySelector('.mk-pane-kebab')) return;
const publishItem = isDraft
? `<li><button class="dropdown-item" id="panePublishBtn-${id}">
<i class="bi bi-send me-2"></i>Publish
</button></li><li><hr class="dropdown-divider"></li>`
: '';
const wrap = document.createElement('div');
wrap.className = 'dropdown mk-pane-kebab ms-1';
wrap.innerHTML = `
<button class="btn btn-sm btn-outline-secondary border-0" data-bs-toggle="dropdown"
aria-expanded="false" title="More">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
${publishItem}
<li><button class="dropdown-item" id="paneRenameBtn-${id}">
<i class="bi bi-pencil me-2"></i>Rename
</button></li>
<li><button class="dropdown-item" id="paneMoveBtn-${id}">
<i class="bi bi-folder-symlink me-2"></i>Move
</button></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item text-danger" id="paneDeleteBtn-${id}">
<i class="bi bi-trash me-2"></i>Delete
</button></li>
</ul>`;
tb.appendChild(wrap);
wrap.querySelector('#paneDeleteBtn-' + id).addEventListener('click', () => _paneTabDelete(id));
wrap.querySelector('#paneRenameBtn-' + id).addEventListener('click', () => {
promptRename(pid, path, newPath => {
const tab = _tabs.find(t => t.id === id);
if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); }
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
});
});
wrap.querySelector('#paneMoveBtn-' + id).addEventListener('click', () => {
promptMove(pid, path, newPath => {
const tab = _tabs.find(t => t.id === id);
if (tab) { tab.path = newPath; _renderTabBar(); }
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
});
});
if (isDraft) {
wrap.querySelector('#panePublishBtn-' + id)?.addEventListener('click', () => _paneTabPublish(id, pid, path));
}
}
function _updateSaveBtnState(id) {
const tab = _tabs.find(t => t.id === id);
const btn = document.getElementById('paneSaveBtn-' + id);
if (!tab || !btn) return;
btn.classList.toggle('d-none', !tab.dirty);
}
// Parse a markdown source into { fm: yamlString, body: string, photos: string[] }
function _parseMdSource(text) {
const out = { fm: '', body: text, photos: [] };
if (!text.startsWith('---')) return out;
const end = text.indexOf('\n---', 3);
if (end === -1) return out;
out.fm = text.substring(3, end).replace(/^\n/, '').replace(/\n$/, '');
out.body = text.substring(end + 4).replace(/^\s*\n/, '');
// photos: inline array → photos: [a, b, c]
const inline = out.fm.match(/^photos:\s*\[(.+?)\]\s*$/m);
if (inline) {
out.photos = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
} else {
// photos: block list → photos:\n - a\n - b
const block = out.fm.match(/^photos:\s*\n((?:[ \t]*-\s*.+\n?)+)/m);
if (block) {
out.photos = [...block[1].matchAll(/^[ \t]*-\s*(.+?)\s*$/gm)]
.map(m => m[1].trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
} else {
// photos: single value on the same line → photos: foo.jpg
const single = out.fm.match(/^photos:\s*([^\s\[].*?)\s*$/m);
if (single) out.photos = [single[1].replace(/^["']|["']$/g, '')];
}
}
return out;
}
function _renderPhotosBanner(el, photos, pid) {
if (!el) return;
if (!photos.length) { el.classList.add('d-none'); el.innerHTML = ''; return; }
el.classList.remove('d-none');
el.innerHTML = photos.map(p => {
const url = _resolveImagePathForEditor(p, pid);
return `<img src="${esc(url)}" alt="" loading="lazy">`;
}).join('');
}
// images/foo.png → /api/files?project_id=X&action=serve&path=source/images/foo.png
// Already-absolute or already-rewritten URLs pass through unchanged.
function _resolveImagePathForEditor(src, pid) {
if (!src) return src;
if (/^(https?:|data:|\/\/)/i.test(src)) return src;
if (src.includes('/api/files')) return src;
let rel = src.replace(/^\.?\//, '');
if (!rel.startsWith('source/')) rel = 'source/' + rel;
return `/api/files?project_id=${pid}&action=serve&path=${encodeURIComponent(rel)}`;
}
// Rewrite ![]() image URLs for in-editor display.
function _rewriteImagePathsForEditor(markdown, pid) {
return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) =>
pre + _resolveImagePathForEditor(src, pid) + post);
}
// Reverse: strip the /api/files prefix back to the original relative form.
function _reverseRewriteImagePaths(markdown, pid) {
const prefix = `/api/files?project_id=${pid}&action=serve&path=`;
return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => {
if (src.startsWith(prefix)) {
let p = decodeURIComponent(src.substring(prefix.length));
if (p.startsWith('source/')) p = p.substring(7);
return pre + p + post;
}
return m;
});
}
async function _paneTabSave(id) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
const status = document.getElementById('paneStatus-' + id);
let content;
if (tab.kind === 'md-editor') {
const mount = _tabMdMounts[id];
if (!mount?.mkMount) { showError('Editor not ready'); return; }
const md = mount.mkMount.getContent() ?? '';
// FM lives as the first ```yaml block; extract it and convert to ---\n...\n---
const fmMatch = md.match(/^```yaml\r?\n([\s\S]*?)\n?```(\r?\n|$)/);
if (fmMatch) {
const fm = fmMatch[1];
const body = md.substring(fmMatch[0].length).replace(/^\s*\n/, '');
content = '---\n' + fm + '\n---\n\n' + body;
} else {
content = md;
}
} else {
if (!_tabCMs[id]) return;
content = _tabCMs[id].getValue();
}
if (status) status.textContent = 'Saving…';
const res = await fetch('/api/files?project_id=' + tab.pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'write', path: tab.path, content }),
});
const data = await res.json();
if (data.ok) {
tab.dirty = false;
if (tab.kind !== 'md-editor') _tabCMs[id]?.markClean();
if (status) status.textContent = 'Saved.';
showSuccess('Saved');
_renderTabBar();
_updateSaveBtnState(id);
} else {
if (status) status.textContent = '';
showError(data.error || 'Save failed');
}
}
async function _paneTabDelete(id) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
confirmAction(`Delete "${tab.name}"? This cannot be undone.`, async () => {
const res = await fetch('/api/files?project_id=' + tab.pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', path: tab.path }),
});
const data = await res.json();
if (data.ok) {
showSuccess('Deleted ' + tab.name);
_closeTab(id);
const browser = document.getElementById('fileBrowser');
if (browser) {
const currentPath = browser.dataset.currentPath || '';
if (typeof loadDir === 'function') loadDir(currentPath);
}
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
} else {
showError(data.error || 'Delete failed');
}
});
}
async function _paneTabPublish(id, pid, path) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
confirmAction('Publish draft to _posts? This moves the file.', async () => {
const relpath = tab.path.replace('source/_drafts/', '');
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'publish', relpath }),
});
const data = await res.json();
if (data.ok) {
showSuccess('Published!');
_closeTab(id);
_postsLoadFn('post');
} else {
showError(data.error || 'Publish failed');
}
});
}
function promptRename(pid, path, onSuccess) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('renameModal'));
const input = document.getElementById('renameInput');
const err = document.getElementById('renameError');
input.value = path.split('/').pop();
err.classList.add('d-none');
const confirm = async () => {
const newName = input.value.trim();
if (!newName) { err.textContent = 'Name required'; err.classList.remove('d-none'); return; }
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'rename', path, newName }),
});
const data = await res.json();
if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; }
modal.hide();
showSuccess('Renamed');
onSuccess(data.newPath);
};
document.getElementById('renameConfirmBtn').onclick = confirm;
input.onkeydown = e => { if (e.key === 'Enter') confirm(); };
modal.show();
setTimeout(() => { input.select(); }, 300);
}
function promptMove(pid, path, onSuccess) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('moveModal'));
const input = document.getElementById('moveInput');
const err = document.getElementById('moveError');
const parts = path.split('/');
parts.pop();
input.value = parts.join('/') + '/';
err.classList.add('d-none');
const confirm = async () => {
const dir = input.value.trim().replace(/\/$/, '');
const file = path.split('/').pop();
const newPath = dir + '/' + file;
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'move', path, newPath }),
});
const data = await res.json();
if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; }
modal.hide();
showSuccess('Moved');
onSuccess(data.newPath);
};
document.getElementById('moveConfirmBtn').onclick = confirm;
input.onkeydown = e => { if (e.key === 'Enter') confirm(); };
modal.show();
setTimeout(() => { const l = input.value.length; input.setSelectionRange(l, l); input.focus(); }, 300);
}
// Modal editor (used from posts pane where there's no tab bar)
let _modalCM = null;
function _renderModalEditor(pid, path, name, content) {
document.getElementById('fileEditName').textContent = name;
document.getElementById('fileEditPath').value = path;
document.getElementById('fileEditPid').value = pid;
document.getElementById('fileEditStatus').textContent = '';
const container = document.getElementById('fileEditCm');
container.innerHTML = '';
if (_modalCM) { try { _modalCM.toTextArea(); } catch(e) {} _modalCM = null; }
_modalCM = CodeMirror(container, {
value: content, mode: modeForFile(name), theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
extraKeys: { 'Ctrl-S': saveFile, 'Cmd-S': saveFile },
});
const modal = document.getElementById('fileEditModal');
bootstrap.Modal.getOrCreateInstance(modal).show();
modal.addEventListener('shown.bs.modal', () => _modalCM?.refresh(), { once: true });
}
async function saveFile() {
const pid = document.getElementById('fileEditPid').value;
const path = document.getElementById('fileEditPath').value;
const content = _modalCM ? _modalCM.getValue() : '';
const status = document.getElementById('fileEditStatus');
status.textContent = 'Saving…';
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'write', path, content }),
});
const data = await res.json();
if (data.ok) { status.textContent = 'Saved.'; showSuccess('Saved'); }
else { status.textContent = ''; showError(data.error || 'Save failed'); }
}
document.getElementById('fileEditSave')?.addEventListener('click', saveFile);
// ── File browser ──────────────────────────────────────────────────────────────
const fileBrowser = document.getElementById('fileBrowser');
if (fileBrowser) {
const pid = fileBrowser.dataset.projectId;
let fileBrowserCurrentPath = '';
async function loadDir(relPath) {
fileBrowserCurrentPath = relPath;
fileBrowser.dataset.currentPath = relPath;
const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`);
const data = await res.json();
if (data.error) { renderBrowserError(data.error); return; }
renderCrumb(relPath, '#fileCrumb', loadDir);
renderEntries(data.entries);
}
function renderEntries(entries) {
const list = document.getElementById('fileList');
if (!entries.length) { list.innerHTML = '<div class="list-group-item text-muted small">Empty directory</div>'; 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' ? `
<div class="dropdown flex-shrink-0">
<button class="btn btn-xs btn-outline-secondary border-0 px-1" data-bs-toggle="dropdown" aria-expanded="false" title="More">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button class="dropdown-item small btn-open-file" data-path="${esc(e.path)}" data-name="${esc(e.name)}">
<i class="bi bi-pencil me-2"></i>Edit
</button></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item small text-danger btn-del-file" data-path="${esc(e.path)}" data-name="${esc(e.name)}">
<i class="bi bi-trash me-2"></i>Delete
</button></li>
</ul>
</div>` : '';
return `<div class="list-group-item list-group-item-action d-flex align-items-center gap-2"
data-type="${e.type}" data-path="${esc(e.path)}" data-name="${esc(e.name)}" style="cursor:pointer">
<i class="bi ${icon} flex-shrink-0"></i>
<span class="flex-grow-1 text-truncate">${esc(e.name)}</span>
${e.size != null ? `<small class="text-muted">${fmtSize(e.size)}</small>` : ''}
${kebab}
</div>`;
}).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 = `<div class="list-group-item text-danger small">${esc(msg)}</div>`;
}
// Drag-and-drop upload — whole left pane as drop zone
const dropZone = fileBrowser.querySelector('.split-list') || fileBrowser;
let dragDepth = 0;
dropZone.addEventListener('dragenter', e => {
e.preventDefault(); dragDepth++;
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
if (--dragDepth <= 0) { dragDepth = 0; dropZone.classList.remove('drag-over'); }
});
dropZone.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
});
dropZone.addEventListener('drop', async e => {
e.preventDefault(); dragDepth = 0; dropZone.classList.remove('drag-over');
const files = [...(e.dataTransfer.files || [])];
if (!files.length) return;
let ok = 0;
for (const file of files) {
const fd = new FormData();
fd.append('project_id', pid);
fd.append('folder', fileBrowserCurrentPath);
fd.append('accept', 'any');
fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
data.ok ? ok++ : showError(`${file.name}: ${data.error || 'Upload failed'}`);
} catch(err) { showError(`${file.name}: ${err.message}`); }
}
if (ok > 0) { showSuccess(`Uploaded ${ok} file${ok > 1 ? 's' : ''}`); loadDir(fileBrowserCurrentPath); }
});
const initialPath = new URLSearchParams(location.search).get('path') || '';
loadDir(initialPath);
}
// ── Command runner ────────────────────────────────────────────────────────────
const commandRunner = document.getElementById('commandRunner');
if (commandRunner) {
const pid = commandRunner.dataset.projectId;
const output = document.getElementById('cmdOutput');
document.getElementById('clearOutput')?.addEventListener('click', () => { output.textContent = ''; });
commandRunner.querySelectorAll('.btn-run-cmd').forEach(btn => {
btn.addEventListener('click', async () => {
const cmd = btn.dataset.cmd;
output.textContent += `\n$ ${cmd}\n`;
btn.disabled = true;
const res = await fetch('/api/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ project_id: parseInt(pid), cmd }),
});
const data = await res.json();
output.textContent += data.error ? `ERROR: ${data.error}\n` : (data.output || '(no output)') + `\nExit: ${data.exit_code}\n`;
output.scrollTop = output.scrollHeight;
btn.disabled = false;
});
});
}
// ── Post tree helpers ─────────────────────────────────────────────────────────
let _postFolderCounter = 0;
function buildPostTree(items) {
const root = { posts: [], children: {} };
for (const item of items) {
const segs = item.folder ? item.folder.split('/').filter(Boolean) : [];
let node = root;
for (const seg of segs) {
if (!node.children[seg]) node.children[seg] = { posts: [], children: {} };
node = node.children[seg];
}
node.posts.push(item);
}
return root;
}
function countTreePosts(node) {
let c = node.posts.length;
for (const child of Object.values(node.children)) c += countTreePosts(child);
return c;
}
function renderPostItem(p, pid, type) {
const rel = esc(p.relpath);
const path = esc(p.path);
const publishItem = type === 'draft'
? `<li><button class="dropdown-item small btn-pub-post" data-relpath="${rel}" data-path="${path}">
<i class="bi bi-send me-2"></i>Publish
</button></li><li><hr class="dropdown-divider"></li>`
: '';
return `<div class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-1 px-2 post-item-row"
data-path="${path}" data-name="${esc(p.filename)}"
data-relpath="${rel}" data-type="${esc(type)}" style="cursor:pointer">
<div class="flex-grow-1 min-w-0">
<div class="text-truncate small">${esc(p.title)}</div>
<small class="text-muted">${p.date ? esc(p.date.substring(0,10)) : '—'} &bull; <code class="small">${esc(p.filename)}</code></small>
</div>
<div class="dropdown flex-shrink-0">
<button class="btn btn-xs btn-outline-secondary border-0 px-1 post-row-menu" data-bs-toggle="dropdown" aria-expanded="false" title="More">
<i class="bi bi-three-dots-vertical"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
${publishItem}
<li><button class="dropdown-item small btn-dup-post" data-relpath="${rel}" data-type="${esc(type)}">
<i class="bi bi-copy me-2"></i>Duplicate
</button></li>
<li><button class="dropdown-item small btn-rename-post" data-path="${path}">
<i class="bi bi-pencil me-2"></i>Rename
</button></li>
<li><button class="dropdown-item small btn-move-post" data-path="${path}">
<i class="bi bi-folder-symlink me-2"></i>Move
</button></li>
<li><hr class="dropdown-divider"></li>
<li><button class="dropdown-item small text-danger btn-del-post" data-relpath="${rel}" data-type="${esc(type)}">
<i class="bi bi-trash me-2"></i>Delete
</button></li>
</ul>
</div>
</div>`;
}
function renderPostTree(node, pid, type, expandPath = []) {
let html = '';
if (node.posts.length) {
html += '<div class="list-group mb-1">';
for (const p of node.posts) html += renderPostItem(p, pid, type);
html += '</div>';
}
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 += `<div class="mb-1">
<div class="post-section-header" data-bs-toggle="collapse" data-bs-target="#${id}" aria-expanded="${isOpen}">
<i class="bi bi-chevron-right post-section-chevron"></i>
<span class="flex-grow-1">${esc(name)}</span>
<span class="badge bg-secondary" style="font-size:.7rem;font-weight:400">${count}</span>
</div>
<div class="collapse ${isOpen ? 'show' : ''} ps-2" id="${id}">
${renderPostTree(child, pid, type, sub)}
</div>
</div>`;
}
return html;
}
function _wirePostList(list, pid, currentTypeRef) {
list.querySelectorAll('.post-item-row').forEach(row => {
row.addEventListener('click', ev => {
if (ev.target.closest('button')) return;
openFileEditor(pid, row.dataset.path, row.dataset.name);
});
});
list.querySelectorAll('.btn-del-post').forEach(btn => {
btn.addEventListener('click', () => confirmAction(`Delete "${btn.dataset.relpath}"?`, async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'DELETE', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ relpath: btn.dataset.relpath, type: btn.dataset.type }),
});
const d = await res.json();
d.ok ? _postsLoadFn(currentTypeRef.type) : showError(d.error || 'Error');
}));
});
list.querySelectorAll('.btn-dup-post').forEach(btn => {
btn.addEventListener('click', async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'duplicate', relpath: btn.dataset.relpath, type: btn.dataset.type }),
});
const d = await res.json();
d.ok ? (showSuccess('Duplicated as ' + d.relpath), _postsLoadFn(currentTypeRef.type)) : showError(d.error || 'Error');
});
});
list.querySelectorAll('.btn-pub-post').forEach(btn => {
btn.addEventListener('click', () => confirmAction('Publish draft to _posts?', async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'publish', relpath: btn.dataset.relpath }),
});
const d = await res.json();
if (d.ok) {
showSuccess('Published!');
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) _closeTab(tab.id);
_postsLoadFn('post');
} else { showError(d.error || 'Error'); }
}));
});
list.querySelectorAll('.btn-rename-post').forEach(btn => {
btn.addEventListener('click', () => {
promptRename(pid, btn.dataset.path, newPath => {
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); }
_postsLoadFn(currentTypeRef.type);
});
});
});
list.querySelectorAll('.btn-move-post').forEach(btn => {
btn.addEventListener('click', () => {
promptMove(pid, btn.dataset.path, newPath => {
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) { tab.path = newPath; _renderTabBar(); }
_postsLoadFn(currentTypeRef.type);
});
});
});
}
// Shared reference so modal "Create" button can reload the list
let _postsLoadFn = () => {};
// ── Posts panel (Hexo) ───────────────────────────────────────────────────────
const postsPanel = document.getElementById('postsPanel');
if (postsPanel) {
const pid = postsPanel.dataset.projectId;
const postsList = document.getElementById('postsList');
const searchResults= document.getElementById('searchResults');
let currentType = 'post';
_postsLoadFn = type => loadPostsByType(type);
function setActiveBtn(btnId) {
['showPosts','showPages'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('btn-outline-primary', id === btnId);
el.classList.toggle('active', id === btnId);
el.classList.toggle('btn-outline-secondary', id !== btnId);
});
}
// ── Filter pill (tag/category from Dashboard) ───────────────────────────────
let _activeFilter = null; // {kind, value}
function _renderFilterBar() {
let bar = document.getElementById('postsFilterBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'postsFilterBar';
bar.className = 'mb-2';
postsList.parentElement.insertBefore(bar, postsList);
}
if (!_activeFilter) { bar.innerHTML = ''; return; }
bar.innerHTML = `<span class="badge bg-info-subtle text-info border d-inline-flex align-items-center gap-1">
<i class="bi bi-funnel-fill"></i>
<span>${esc(_activeFilter.kind)}: <strong>${esc(_activeFilter.value)}</strong></span>
<button type="button" class="btn-close btn-close-white btn-close-sm ms-1" id="clearFilterBtn"
style="font-size:.6rem" aria-label="Clear filter"></button>
</span>`;
document.getElementById('clearFilterBtn').addEventListener('click', () => {
_activeFilter = null;
const url = new URL(location);
url.searchParams.delete('filter');
history.replaceState({}, '', url);
loadPostsByType(currentType);
});
}
function _applyFilter(items) {
if (!_activeFilter) return items;
const { kind, value } = _activeFilter;
return items.filter(it => {
const list = kind === 'tag' ? (it.tags || []) : (it.categories || []);
return list.some(v => String(v).toLowerCase() === value.toLowerCase());
});
}
async function loadPostsByType(type) {
// Drafts are merged into the posts view — always normalise to 'post'
if (type === 'draft') type = 'post';
currentType = type;
window._postsCurrentType = type;
setActiveBtn(type === 'page' ? 'showPages' : 'showPosts');
const sq = document.getElementById('searchQuery');
if (sq) sq.value = '';
searchResults.classList.add('d-none');
postsList.classList.remove('d-none');
postsList.innerHTML = '<div class="text-muted small">Loading…</div>';
if (type === 'post') {
// Fetch posts + drafts together
const [pr, dr] = await Promise.all([
fetch(`/api/posts?project_id=${pid}&type=post`),
fetch(`/api/posts?project_id=${pid}&type=draft`),
]);
const [postsData, draftsData] = await Promise.all([pr.json(), dr.json()]);
const posts = _applyFilter(postsData.items || []);
const drafts = _applyFilter(draftsData.items || []);
if (!posts.length && !drafts.length) {
postsList.innerHTML = `<div class="text-muted small">${_activeFilter ? 'No posts match this filter.' : 'No posts yet.'}</div>`;
_renderFilterBar();
return;
}
let html = '';
if (drafts.length) {
_postFolderCounter = 0;
const draftTree = renderPostTree(buildPostTree(drafts), pid, 'draft');
html += `<div class="mb-2">
<div class="post-section-header" data-bs-toggle="collapse" data-bs-target="#vfDrafts" aria-expanded="false">
<i class="bi bi-chevron-right post-section-chevron"></i>
<i class="bi bi-pencil-square me-1" style="font-size:.75rem;opacity:.7"></i>
<span class="flex-grow-1">Drafts</span>
<span class="badge bg-secondary" style="font-size:.7rem;font-weight:400">${drafts.length}</span>
</div>
<div class="collapse ps-2" id="vfDrafts">
<div class="posts-tree">${draftTree}</div>
</div>
</div>`;
}
if (posts.length) {
const expandPath = posts[0]?.folder ? posts[0].folder.split('/').filter(Boolean) : [];
_postFolderCounter = 0;
html += `<div class="posts-tree">${renderPostTree(buildPostTree(posts), pid, 'post', expandPath)}</div>`;
}
postsList.innerHTML = html;
_wirePostList(postsList, pid, { type: 'post' });
_renderFilterBar();
} else {
// Pages
const res = await fetch(`/api/posts?project_id=${pid}&type=${type}`);
const data = await res.json();
if (data.missing_dir) {
postsList.innerHTML = `<div class="text-muted small"><code>source/</code> not found.</div>`;
_renderFilterBar();
return;
}
const items = _applyFilter(data.items || []);
if (!items.length) {
postsList.innerHTML = `<div class="text-muted small">${_activeFilter ? 'No pages match this filter.' : 'No pages yet.'}</div>`;
_renderFilterBar();
return;
}
const expandPath = items[0]?.folder ? items[0].folder.split('/').filter(Boolean) : [];
_postFolderCounter = 0;
postsList.innerHTML = `<div class="posts-tree">${renderPostTree(buildPostTree(items), pid, type, expandPath)}</div>`;
_wirePostList(postsList, pid, { type });
_renderFilterBar();
}
}
// Read ?filter=tag:foo or ?filter=category:bar from URL
const urlFilter = new URLSearchParams(location.search).get('filter');
if (urlFilter && urlFilter.includes(':')) {
const [k, ...rest] = urlFilter.split(':');
if (k === 'tag' || k === 'category') {
_activeFilter = { kind: k, value: rest.join(':') };
}
}
document.getElementById('showPosts')?.addEventListener('click', () => loadPostsByType('post'));
document.getElementById('showPages')?.addEventListener('click', () => loadPostsByType('page'));
let _newTypeTarget = 'post';
function openNewPostModal(type) {
_newTypeTarget = type;
const labels = { post: 'New Post', draft: 'New Draft', page: 'New Page' };
document.getElementById('newPostModalTitle').textContent = labels[type] ?? 'New';
document.getElementById('newPostTitle').value = '';
document.getElementById('newPostFolder').value = '';
document.getElementById('newPostError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).show();
setTimeout(() => document.getElementById('newPostTitle').focus(), 300);
}
document.getElementById('newItemBtn')?.addEventListener('click', () => {
openNewPostModal(currentType === 'page' ? 'page' : 'post');
});
postsPanel.querySelectorAll('.new-type-btn').forEach(btn => {
btn.addEventListener('click', () => openNewPostModal(btn.dataset.type));
});
document.getElementById('newPostCreateBtn')?.addEventListener('click', async () => {
const title = document.getElementById('newPostTitle').value.trim();
const folder = document.getElementById('newPostFolder').value.trim();
const errEl = document.getElementById('newPostError');
if (!title) { errEl.textContent = 'Title required'; errEl.classList.remove('d-none'); return; }
const date = new Date().toISOString().replace('T', ' ').substring(0, 19);
const fm = `---\ntitle: "${title.replace(/"/g, '\\"')}"\ndate: ${date}\ntags: []\n---`;
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ type: _newTypeTarget, title, folder, frontmatter: fm }),
});
const data = await res.json();
if (!data.ok) { errEl.textContent = data.error || 'Error'; errEl.classList.remove('d-none'); return; }
bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).hide();
await loadPostsByType('post');
openFileEditor(pid, data.path, data.filename);
});
document.getElementById('newPostTitle')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('newPostCreateBtn')?.click();
});
let _searchTimer = null;
document.getElementById('searchQuery')?.addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (!q) {
searchResults.classList.add('d-none');
postsList.classList.remove('d-none');
return;
}
postsList.classList.add('d-none');
searchResults.classList.remove('d-none');
searchResults.innerHTML = '<div class="text-muted small">Searching…</div>';
_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 = `<div class="text-danger small">${esc(data.error)}</div>`; return; }
if (!data.results.length) { searchResults.innerHTML = '<div class="text-muted small">No results.</div>'; return; }
searchResults.innerHTML = data.results.map(r => `
<div class="list-group-item list-group-item-action py-2 search-result"
data-path="${esc(r.file)}" data-name="${esc(r.file.split('/').pop())}" style="cursor:pointer">
<div class="d-flex gap-2 justify-content-between">
<code class="text-primary small">${esc(r.file)}</code>
<small class="text-muted flex-shrink-0">L${r.line}</small>
</div>
<small class="text-muted text-truncate d-block">${esc(r.content)}</small>
</div>`).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 = `<div class="col text-danger small">${esc(data.error)}</div>`; return; }
renderCrumb(relPath, '#mediaCrumb', loadMedia);
renderMediaGrid(data.entries, relPath);
}
function renderMediaGrid(entries, relPath) {
const grid = document.getElementById('mediaGrid');
if (!entries.length) { grid.innerHTML = '<div class="col text-muted small">Empty directory</div>'; return; }
grid.innerHTML = entries.map(e => {
if (e.type === 'dir') {
return `<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100 text-center" style="cursor:pointer" data-nav-path="${esc(e.path)}">
<div class="card-body p-2 d-flex flex-column align-items-center justify-content-center" style="min-height:100px">
<i class="bi bi-folder-fill text-warning fs-1"></i>
</div>
<div class="card-footer py-1 px-2"><small class="text-truncate d-block">${esc(e.name)}</small></div>
</div>
</div>`;
}
const thumb = isImage(e.name)
? `<img src="/api/files?project_id=${pid}&path=${encodeURIComponent(e.path)}&action=serve"
class="img-fluid" style="max-height:80px;object-fit:cover" loading="lazy">`
: `<i class="bi ${isVideo(e.name) ? 'bi-film' : isAudio(e.name) ? 'bi-music-note' : 'bi-file-earmark'} fs-1 text-secondary"></i>`;
const fileUrl = projectUrl ? rtrim(projectUrl, '/') + '/' + e.path : e.path;
return `<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card h-100">
<div class="card-body p-2 d-flex align-items-center justify-content-center bg-dark" style="min-height:100px">
${thumb}
</div>
<div class="card-footer py-1 px-2">
<small class="text-truncate d-block" title="${esc(e.name)}">${esc(e.name)}</small>
<small class="text-muted">${fmtSize(e.size)}</small>
<button class="btn btn-xs btn-outline-secondary py-0 float-end btn-copy-url"
data-url="${esc(fileUrl)}" title="Copy URL">
<i class="bi bi-clipboard"></i>
</button>
</div>
</div>
</div>`;
}).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 = '<li class="breadcrumb-item"><a href="#" data-path="">Root</a></li>';
let acc = '';
for (const p of parts) {
acc = acc ? acc + '/' + p : p;
html += `<li class="breadcrumb-item"><a href="#" data-path="${esc(acc)}">${esc(p)}</a></li>`;
}
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 => `<option value="${esc(s)}">${esc(s)}</option>`).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 = `<i class="bi bi-git me-1"></i>${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 = `<div class="list-group list-group-flush small mb-1">` +
files.map(f => `<div class="list-group-item py-1 px-2 d-flex align-items-center gap-2">
<input type="checkbox" class="form-check-input git-file-check flex-shrink-0 mt-0"
data-file="${esc(f.file)}">
<code class="git-xy flex-shrink-0">${esc(f.xy)}</code>
<span class="text-truncate flex-grow-1">${esc(f.file)}</span>
<button class="btn btn-xs btn-outline-secondary btn-git-diff flex-shrink-0"
data-file="${esc(f.file)}">Diff</button>
</div>`).join('') + `</div>
<button class="btn btn-sm btn-outline-success mb-2 mt-1" id="gitAddSelectedBtn">
<i class="bi bi-plus-lg me-1"></i>Add selected
</button>`;
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 = '<div class="text-muted small">No commits yet.</div>'; return; }
tbl.innerHTML = `<div class="list-group">` + data.commits.map(c => `
<div class="list-group-item list-group-item-action py-2 btn-git-show-commit"
data-hash="${esc(c.hash)}" style="cursor:pointer">
<div class="d-flex justify-content-between gap-2">
<span class="text-truncate">${esc(c.subject)}</span>
<small class="text-muted flex-shrink-0">${esc(c.rel)}</small>
</div>
<small class="text-muted"><code>${esc(c.short)}</code> &bull; ${esc(c.author)}</small>
</div>`).join('') + `</div>`;
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 => `
<div class="d-flex align-items-center gap-2 mb-1 small">
<span class="flex-grow-1 font-monospace">${esc(b.name)}</span>
${b.current ? '<span class="badge bg-primary">current</span>' : ''}
${!b.current ? `<button class="btn btn-xs btn-outline-secondary btn-checkout"
data-branch="${esc(b.name)}">Switch</button>` : ''}
</div>`).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 `<span class="diff-add">${esc(line)}</span>`;
if (line.startsWith('-') && !line.startsWith('---')) return `<span class="diff-remove">${esc(line)}</span>`;
if (line.startsWith('@@')) return `<span class="diff-hunk">${esc(line)}</span>`;
if (/^(diff |index |---|[+]{3})/.test(line)) return `<span class="diff-meta">${esc(line)}</span>`;
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 =>
`<option value="${esc(y.path)}">${esc(y.label)}</option>`
).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 = `<div class="text-danger small p-3">${esc(data.error)}</div>`;
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 '<div class="text-muted small">None found.</div>';
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 `<a class="tag-item" href="${href}" style="font-size:${size}%"
title="Show posts with this ${kind}">${esc(name)}<sup class="text-muted ms-1">${count}</sup></a>`;
}).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 = '<div class="list-group-item text-muted small">No history yet.</div>';
} else {
list.innerHTML = data.history.map(h => `
<div class="list-group-item py-2">
<div class="d-flex justify-content-between gap-2 mb-1">
<code class="small">${esc(h.cmd)}</code>
<span class="badge ${h.exit_code === 0 ? 'bg-success' : 'bg-danger'} flex-shrink-0">
Exit ${h.exit_code}
</span>
</div>
<small class="text-muted">${esc(h.run_at)}</small>
${h.output ? `<pre class="mt-1 mb-0 small" style="max-height:80px;overflow-y:auto;background:transparent">${esc(h.output.substring(0,500))}</pre>` : ''}
</div>`).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 = '<div class="text-muted small">No templates yet.</div>';
return;
}
list.innerHTML = data.templates.map(t => `
<div class="card mb-2">
<div class="card-body py-2 d-flex align-items-center gap-3">
<div class="flex-grow-1">
<strong class="small">${esc(t.name)}</strong>
<span class="badge bg-secondary ms-2">${esc(t.type)}</span>
</div>
<button class="btn btn-xs btn-outline-secondary btn-edit-template" data-id="${t.id}"
data-name="${esc(t.name)}" data-content="${esc(t.content ?? '')}">Edit</button>
<button class="btn btn-xs btn-outline-danger btn-del-template" data-id="${t.id}">
<i class="bi bi-trash"></i>
</button>
</div>
</div>`).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 = '<div class="text-muted small">No snippets yet.</div>';
return;
}
list.innerHTML = data.snippets.map(s => `
<div class="card mb-2">
<div class="card-body py-2 d-flex align-items-center gap-3">
<span class="flex-grow-1 small fw-semibold">${esc(s.name)}</span>
<button class="btn btn-xs btn-outline-secondary btn-edit-snippet" data-id="${s.id}"
data-name="${esc(s.name)}" data-content="${esc(s.content ?? '')}">Edit</button>
<button class="btn btn-xs btn-outline-danger btn-del-snippet" data-id="${s.id}">
<i class="bi bi-trash"></i>
</button>
</div>
</div>`).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 = '<div class="text-muted small">No entries.</div>';
} else {
auditTable.innerHTML = `<table class="table table-sm table-hover small">
<thead><tr>
<th>Time</th><th>User</th><th>Project</th><th>Action</th><th>Detail</th><th>IP</th>
</tr></thead>
<tbody>` + data.entries.map(e => `<tr>
<td class="text-muted text-nowrap">${esc(e.created_at)}</td>
<td>${esc(e.username ?? e.user_id ?? '—')}</td>
<td>${esc(e.project_name ?? (e.project_id ? '#' + e.project_id : '—'))}</td>
<td><code>${esc(e.action)}</code></td>
<td class="text-truncate" style="max-width:200px">${esc(e.detail ?? '')}</td>
<td class="text-muted">${esc(e.ip ?? '')}</td>
</tr>`).join('') + `</tbody></table>`;
}
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 = '<p class="text-muted small">No projects found.</p>'; return; }
listDiv.innerHTML = data.found.map(p => `
<div class="card mb-2"><div class="card-body d-flex align-items-center gap-3 py-2">
<div class="flex-grow-1 min-w-0">
<strong>${esc(p.name)}</strong>
<small class="text-muted d-block text-truncate">${esc(p.path)}</small>
</div>
<span class="badge bg-secondary">${esc(p.type_name)}</span>
<button class="btn btn-sm btn-primary btn-add-found flex-shrink-0"
data-name="${esc(p.name)}" data-path="${esc(p.path)}" data-type="${esc(p.type)}">Add</button>
</div></div>`).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 = '<i class="bi bi-check-lg"></i>'; 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 = '<div class="list-group-item text-muted small">No recent files yet — open or save a file to start tracking.</div>';
return;
}
list.innerHTML = d.items.map(it => `
<div class="list-group-item d-flex align-items-center gap-2">
<i class="bi bi-file-text text-secondary"></i>
<div class="flex-grow-1 min-w-0">
<div class="text-truncate"><strong>${esc(it.name)}</strong></div>
<small class="text-muted text-truncate d-block">${esc(it.dir || '/')}</small>
</div>
<small class="text-muted flex-shrink-0">${fmtAgo(it.opened_at)}</small>
<button class="btn btn-xs btn-outline-secondary py-0 px-1 btn-edit-recent flex-shrink-0"
data-path="${esc(it.path)}" data-name="${esc(it.name)}" title="Edit">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 btn-remove-recent flex-shrink-0"
data-path="${esc(it.path)}" title="Remove from recent">
<i class="bi bi-x-lg"></i>
</button>
</div>`).join('');
list.querySelectorAll('.btn-edit-recent').forEach(b => {
b.addEventListener('click', () => openFileEditor(pid, b.dataset.path, b.dataset.name));
});
list.querySelectorAll('.btn-remove-recent').forEach(b => {
b.addEventListener('click', async () => {
await fetch('/api/recent?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'remove', path: b.dataset.path }),
});
load();
});
});
}
document.getElementById('recentClearBtn').addEventListener('click', () => {
confirmAction('Clear all recent files for this project?', async () => {
await fetch('/api/recent?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'clear' }),
});
load();
});
});
load();
})();
// ── Disk usage: dashboard cards + project page badge ─────────────────────────
(() => {
// Project header pill
const badge = document.getElementById('diskBadge');
if (badge) {
const pid = badge.dataset.projectId;
fetch('/api/disk?project_id=' + pid).then(r => r.json()).then(d => {
if (d.size?.human) {
document.getElementById('diskBadgeValue').textContent = d.size.human;
badge.classList.remove('d-none');
}
});
}
// Dashboard card pills (bulk)
const cards = [...document.querySelectorAll('.project-disk')];
if (cards.length) {
const ids = cards.map(c => c.dataset.projectId).join(',');
fetch('/api/disk?ids=' + ids).then(r => r.json()).then(d => {
cards.forEach(c => {
const item = d.items?.[c.dataset.projectId];
if (item?.human) {
c.querySelector('.project-disk-value').textContent = item.human;
c.classList.remove('d-none');
}
});
});
}
})();
// ── Activity feed (dashboard) ────────────────────────────────────────────────
(() => {
const feed = document.getElementById('activityFeed');
if (!feed) return;
const ACTION_LABELS = {
git_commit: 'commit', git_push: 'push', git_pull: 'pull', git_merge: 'merge',
git_reset: 'reset', git_stage: 'stage', git_unstage: 'unstage',
git_discard: 'discard', git_fetch: 'fetch',
backup_download: 'backup', link_scan: 'link scan',
theme_switch: 'theme switch', theme_clone: 'theme clone', theme_delete: 'theme delete',
theme_git_pull: 'theme pull', theme_git_push: 'theme push', theme_git_fetch: 'theme fetch',
plugin_install: 'plugin install', plugin_uninstall: 'plugin uninstall',
scheduled_build: 'scheduled build',
bot_start: 'bot started', bot_stop: 'bot stopped', bot_restart: 'bot restarted',
botconfig_streamer_add: 'streamer added', botconfig_streamer_save: 'streamer updated',
botconfig_streamer_remove: 'streamer removed',
botconfig_rss_add: 'feed added', botconfig_rss_save: 'feed updated',
botconfig_rss_remove: 'feed removed',
botconfig_mastodon_add: 'mastodon added', botconfig_mastodon_save: 'mastodon updated',
botconfig_mastodon_remove: 'mastodon removed',
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
botconfig_linkedin_page_remove: 'linkedin page removed',
linkedin_oauth_connect: 'linkedin connected',
botcompose_rss_repost: 'rss repost queued',
botcompose_mastodon_post: 'mastodon posted',
botcompose_linkedin_post: 'linkedin posted',
file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload',
post_create: 'post created', post_delete: 'post deleted',
post_publish: 'post published', post_duplicate: 'post duplicated',
draft_create: 'draft created', draft_update: 'draft updated',
draft_delete: 'draft deleted', draft_publish: 'draft published',
command_run: 'command run',
project_add: 'project added', project_delete: 'project removed',
project_rename: 'project renamed', project_type_change: 'type changed',
project_pin: 'pinned', project_unpin: 'unpinned',
project_setting: 'setting changed',
scan_path_add: 'scan path added', scan_path_delete: 'scan path removed',
schedule_create: 'schedule added', schedule_update: 'schedule updated',
schedule_delete: 'schedule removed',
template_create: 'template added', template_update: 'template updated',
template_delete: 'template deleted',
snippet_create: 'snippet added', snippet_update: 'snippet updated',
snippet_delete: 'snippet deleted',
};
const ACTION_ICONS = {
git_commit: 'bi-git', git_push: 'bi-arrow-up-circle', git_pull: 'bi-arrow-down-circle',
git_fetch: 'bi-arrow-repeat', git_merge: 'bi-sign-merge-right',
git_reset: 'bi-arrow-counterclockwise',
git_stage: 'bi-plus-circle', git_unstage: 'bi-dash-circle', git_discard: 'bi-x-circle',
backup_download: 'bi-download', link_scan: 'bi-link-45deg',
theme_switch: 'bi-palette', theme_clone: 'bi-cloud-download', theme_delete: 'bi-trash',
theme_git_pull: 'bi-arrow-down-circle', theme_git_push: 'bi-arrow-up-circle',
theme_git_fetch: 'bi-arrow-repeat',
plugin_install: 'bi-puzzle', plugin_uninstall: 'bi-puzzle',
scheduled_build: 'bi-clock',
bot_start: 'bi-play-circle', bot_stop: 'bi-stop-circle', bot_restart: 'bi-arrow-clockwise',
botconfig_streamer_add: 'bi-twitch', botconfig_streamer_save: 'bi-twitch',
botconfig_streamer_remove: 'bi-twitch',
botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss',
botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon',
botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin',
linkedin_oauth_connect: 'bi-linkedin',
botcompose_rss_repost: 'bi-arrow-repeat',
botcompose_mastodon_post: 'bi-mastodon',
botcompose_linkedin_post: 'bi-linkedin',
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 = '<div class="list-group-item text-muted small bg-transparent">No activity yet.</div>';
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
? `<a href="/project/${e.project_id}" class="text-decoration-none">${esc(e.project_name)}</a>`
: '<span class="text-muted">—</span>';
const detail = e.detail
? `<small class="text-muted ms-1">${esc(e.detail)}</small>` : '';
return `<div class="list-group-item bg-transparent d-flex align-items-center gap-2 small">
<i class="bi ${icon} text-secondary"></i>
<span class="badge bg-secondary-subtle text-body-secondary border">${esc(label)}</span>
${proj}
${detail}
<small class="text-muted ms-auto">${esc(e.created_at)}</small>
</div>`;
}).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 `<span class="badge bg-danger">err</span> <small class="text-muted">${esc(error)}</small>`;
if (code == null) return `<span class="badge bg-warning text-dark">?</span>`;
if (code >= 400) return `<span class="badge bg-danger">${code}</span>`;
if (code >= 300) return `<span class="badge bg-warning text-dark">${code}</span>`;
return `<span class="badge bg-success">${code}</span>`;
}
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, <strong class="${r.broken > 0 ? 'text-danger' : 'text-success'}">${r.broken} broken</strong>`;
if (!d.results.length) {
wrap.innerHTML = `<div class="alert alert-success py-2 small">${onlyB.checked ? 'No broken links 🎉' : 'No links recorded.'}</div>`;
return;
}
wrap.innerHTML = `<table class="table table-sm align-middle">
<thead><tr><th style="width:80px">Status</th><th>URL</th><th>Source</th></tr></thead>
<tbody>${d.results.map(x => `
<tr>
<td>${statusBadge(x.status_code, x.error)}</td>
<td><a href="${esc(x.url)}" target="_blank" rel="noopener" class="text-truncate d-inline-block" style="max-width:480px">${esc(x.url)}</a></td>
<td><code class="small">${esc(x.source)}</code></td>
</tr>`).join('')}
</tbody></table>`;
}
btn.addEventListener('click', async () => {
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>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 '<span class="badge bg-secondary-subtle text-body-secondary border small">no git</span>';
const parts = [`<i class="bi bi-git"></i> ${esc(g.branch || '?')}`];
if (g.dirty) parts.push('<span class="text-warning">● dirty</span>');
if (g.ahead) parts.push(`<span class="text-info">↑${g.ahead}</span>`);
if (g.behind) parts.push(`<span class="text-info">↓${g.behind}</span>`);
return `<span class="small">${parts.join(' ')}</span>`;
}
function renderTheme(t) {
const g = t.git;
return `<div class="col-md-6 col-xl-4">
<div class="card h-100 ${t.active ? 'border-primary' : ''}">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-palette text-primary"></i>
<h6 class="card-title mb-0 text-truncate">${esc(t.name)}</h6>
${t.active ? '<span class="badge bg-primary ms-auto">active</span>' : ''}
</div>
<div class="small text-muted text-truncate mb-2">${gitChip(g)}</div>
${g.remote ? `<div class="small text-truncate mb-1"><code>${esc(g.remote)}</code></div>` : ''}
${g.commit ? `<div class="small text-muted text-truncate mb-2">${esc(g.commit)}</div>` : ''}
</div>
<div class="card-footer d-flex gap-1 flex-wrap">
${!t.active ? `<button class="btn btn-sm btn-outline-primary btn-theme-switch" data-name="${esc(t.name)}">Activate</button>` : ''}
<a class="btn btn-sm btn-outline-secondary" href="/project/${pid}?tab=files&path=themes/${encodeURIComponent(t.name)}/" title="Edit theme files">
<i class="bi bi-pencil"></i>
</a>
${g.has_git ? `
<button class="btn btn-sm btn-outline-secondary btn-theme-git" data-name="${esc(t.name)}" data-op="pull" title="Pull"><i class="bi bi-arrow-down-circle"></i></button>
<button class="btn btn-sm btn-outline-secondary btn-theme-git" data-name="${esc(t.name)}" data-op="push" title="Push"><i class="bi bi-arrow-up-circle"></i></button>
` : ''}
${!t.active ? `<button class="btn btn-sm btn-outline-danger ms-auto btn-theme-delete" data-name="${esc(t.name)}" title="Delete"><i class="bi bi-trash"></i></button>` : ''}
</div>
</div></div>`;
}
async function load() {
list.innerHTML = '<div class="col-12 text-muted small">Loading…</div>';
const d = await (await fetch('/api/themes?project_id=' + pid)).json();
if (d.no_themes_dir) {
list.innerHTML = '<div class="col-12 alert alert-warning small">No <code>themes/</code> directory found in this project.</div>';
return;
}
if (!d.themes.length) {
list.innerHTML = '<div class="col-12 text-muted small">No themes installed yet. Clone one from a git URL.</div>';
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
? `<span class="badge bg-success-subtle text-success border">v${esc(p.installed)}</span>`
: `<span class="badge bg-warning-subtle text-warning border">not installed (run npm install)</span>`;
return `<div class="col-md-6 col-xl-4">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-1">
<i class="bi bi-puzzle text-primary"></i>
<h6 class="card-title mb-0 text-truncate">${esc(p.name)}</h6>
</div>
<div class="small mb-1">
${installed}
<small class="text-muted ms-1">range ${esc(p.version)}</small>
</div>
${p.description ? `<p class="small text-muted mb-2">${esc(p.description)}</p>` : ''}
<div class="small">
<a href="${esc(p.npm)}" target="_blank" rel="noopener">npm</a>
${p.repository ? ` · <a href="${esc(p.repository)}" target="_blank" rel="noopener">repo</a>` : ''}
${p.homepage && p.homepage !== p.repository ? ` · <a href="${esc(p.homepage)}" target="_blank" rel="noopener">docs</a>` : ''}
</div>
</div>
<div class="card-footer">
<button class="btn btn-sm btn-outline-danger btn-plugin-uninstall" data-name="${esc(p.name)}">
<i class="bi bi-trash me-1"></i>Uninstall
</button>
</div>
</div></div>`;
}
async function load() {
list.innerHTML = '<div class="col-12 text-muted small">Loading…</div>';
const d = await (await fetch('/api/plugins?project_id=' + pid)).json();
if (d.no_package_json) {
list.innerHTML = '<div class="col-12 alert alert-warning small">No <code>package.json</code> found in this project.</div>';
return;
}
if (!d.plugins.length) {
list.innerHTML = '<div class="col-12 text-muted small">No <code>hexo-*</code> plugins installed.</div>';
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 = '<span class="spinner-border spinner-border-sm me-1"></span>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 = `<div class="alert alert-info py-2 small mb-0">
No visits recorded yet. Configure the access-log path below and click
<strong>Import now</strong> to load history.
</div>`;
}
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 `<line x1="${padL}" y1="${y}" x2="${padL+innerW}" y2="${y}" stroke="#30363d" stroke-dasharray="2,3"/>
<text x="${padL-4}" y="${y+3}" text-anchor="end" font-size="10" fill="#6e7681">${v}</text>`;
}).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 `<text x="${x}" y="${h-4}" text-anchor="middle" font-size="10" fill="#6e7681">${
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}</text>`;
}).join('');
return `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="xMidYMid meet">
${ticks}
<path d="${fill}" fill="rgba(88,166,255,.12)" stroke="none"/>
<path d="${path(prvArr)}" fill="none" stroke="#6e7681" stroke-width="1" stroke-dasharray="3,3"/>
<path d="${path(curArr)}" fill="none" stroke="#58a6ff" stroke-width="1.7"/>
${labels}
</svg>`;
}
// 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(`<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${barW.toFixed(1)}"
height="${bh.toFixed(1)}" fill="#388bfd" opacity=".75"/>`);
}
const labels = [0, 6, 12, 18].map(i => {
const x = i * (barW + gap) + barW / 2;
return `<text x="${x.toFixed(1)}" y="${h-1}" text-anchor="middle" font-size="9" fill="#6e7681">${i}h</text>`;
}).join('');
return `<svg viewBox="0 0 ${w} ${h}" width="100%" height="${h}" preserveAspectRatio="xMidYMid meet">
${bars.join('')}${labels}
</svg>`;
}
function renderTable(rows, html) {
if (!rows.length) return '<div class="text-muted small">—</div>';
return html;
}
async function load() {
body.innerHTML = '<div class="text-muted small">Loading…</div>';
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 = `<div class="text-danger small">${esc(d.error)}</div>`; 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 => `
<tr>
<td><code class="small text-truncate d-inline-block" style="max-width:340px">${esc(p.path)}</code></td>
<td class="text-end">${p.views}</td>
<td class="text-end text-muted">${p.uniques}</td>
</tr>`).join('');
const refs = d.top_refs.length
? d.top_refs.map(r => `<tr>
<td><span class="text-truncate d-inline-block" style="max-width:340px">${esc(r.referrer)}</span></td>
<td class="text-end">${r.views}</td>
</tr>`).join('')
: '';
const rows404 = (d.top_404s || []).map(r => `<tr>
<td><code class="small text-truncate d-inline-block" style="max-width:340px">${esc(r.path)}</code></td>
<td class="text-end">${r.hits}</td>
<td class="text-end text-muted small">${esc((r.last_hit || '').replace('T', ' ').slice(0, 16))}</td>
</tr>`).join('');
body.innerHTML = `
<div class="row g-3 mb-3">
<div class="col-md-3 col-6">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Views (last ${d.days}d)</div>
<div class="h3 mb-0">${d.window.views}</div>
<small class="${dV.cls}">${dV.txt}</small>
<small class="text-muted ms-1">vs prev ${d.days}d (${d.previous.views})</small>
</div>
</div>
<div class="col-md-3 col-6">
<div class="border rounded p-3 h-100">
<div class="text-muted small" title="Sum of per-day unique visitors. Daily-rotating salt means visitors are not linked across days.">
Unique visitors <i class="bi bi-info-circle small"></i>
</div>
<div class="h3 mb-0">${d.window.uniques}</div>
<small class="${dU.cls}">${dU.txt}</small>
<small class="text-muted ms-1">vs prev (${d.previous.uniques})</small>
</div>
</div>
<div class="col-md-3 col-6">
<div class="border rounded p-3 h-100">
<div class="text-muted small">All-time views</div>
<div class="h3 mb-0">${d.all_time.views}</div>
<small class="text-muted">${d.all_time.uniques} uniques</small>
</div>
</div>
<div class="col-md-3 col-6">
<div class="border rounded p-3 h-100">
<div class="text-muted small">Tracking since</div>
<div class="h6 mb-0">${esc((d.all_time.first_seen || '—').slice(0, 10))}</div>
<small class="text-muted">last hit ${esc((d.all_time.last_seen || '—').slice(0, 16).replace('T', ' '))}</small>
</div>
</div>
</div>
<div class="border rounded p-2 mb-3">
<div class="d-flex align-items-center mb-1 small text-muted">
<span><span style="color:#58a6ff">●</span> last ${d.days}d</span>
<span class="ms-3"><span style="color:#6e7681">— —</span> previous ${d.days}d</span>
</div>
${renderLineChart(d.series, d.prev_series || [], d.days)}
</div>
<div class="row g-3">
<div class="col-md-7">
<h6 class="small text-muted mb-2">Top pages</h6>
<table class="table table-sm table-borderless mb-3">
<thead><tr><th>Path</th><th class="text-end">Views</th><th class="text-end">Uniques</th></tr></thead>
<tbody>${top}</tbody>
</table>
</div>
<div class="col-md-5">
<h6 class="small text-muted mb-2">Referrers</h6>
${renderTable(d.top_refs, `<table class="table table-sm table-borderless mb-3">
<thead><tr><th>From</th><th class="text-end">Views</th></tr></thead>
<tbody>${refs}</tbody>
</table>`)}
<h6 class="small text-muted mb-2 mt-1">Hour of day</h6>
<div class="border rounded p-2 mb-3">${renderHours(d.hours || [])}</div>
</div>
</div>
${rows404 ? `
<h6 class="small text-muted mb-2 mt-2">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>
Top 404s in this window
</h6>
<table class="table table-sm table-borderless mb-0">
<thead><tr><th>Path</th><th class="text-end">Hits</th><th class="text-end">Last seen</th></tr></thead>
<tbody>${rows404}</tbody>
</table>` : ''}`;
}
range.addEventListener('change', load);
refresh?.addEventListener('click', load);
load();
})();
// ── Project sidebar collapse toggle ──────────────────────────────────────────
(() => {
const sb = document.getElementById('projectSidebar');
if (!sb) return;
const KEY = 'hackmancms_sidebar_collapsed';
if (localStorage.getItem(KEY) === '1') sb.classList.add('collapsed');
document.getElementById('sidebarToggle')?.addEventListener('click', () => {
sb.classList.toggle('collapsed');
localStorage.setItem(KEY, sb.classList.contains('collapsed') ? '1' : '0');
});
})();
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
(() => {
let chord = null;
let chordTimer = null;
const isTyping = () => {
const el = document.activeElement;
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
function handle(key) {
if (chord === 'g') {
chord = null;
if (key === 'd') location.href = '/';
else if (key === 's') location.href = '/settings';
else if (key === 'a') location.href = '/audit';
return;
}
if (key === '?') {
const m = document.getElementById('shortcutsModal');
if (m) bootstrap.Modal.getOrCreateInstance(m).show();
return;
}
if (key === 'g') {
chord = 'g';
clearTimeout(chordTimer);
chordTimer = setTimeout(() => { chord = null; }, 1200);
return;
}
// Project page only
if (key === 'n') {
const btn = document.getElementById('newItemBtn');
if (btn) { btn.click(); return; }
}
if (key === 'b') {
const btn = document.querySelector('.btn-run-cmd[data-cmd="generate"]');
if (btn) { btn.click(); showSuccess('Triggered generate'); return; }
}
}
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (isTyping()) return;
if (e.key === '?') { e.preventDefault(); handle('?'); return; }
if (/^[a-z]$/.test(e.key)) handle(e.key);
});
})();
// ── Restore persisted editor tabs on page load ───────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
setTimeout(() => { try { _restoreTabState(); } catch(e) {} }, 50);
});
// ── Bot: control panel ────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botControlPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
const dot = document.getElementById('botStatusDot');
const txt = document.getElementById('botStatusText');
const since = document.getElementById('botSince');
const svc = document.getElementById('botService');
const outputWrap = document.getElementById('botCmdOutputWrap');
const output = document.getElementById('botCmdOutput');
function setStatus(data) {
const active = data.active;
dot.style.background = active ? '#198754' : '#dc3545';
txt.textContent = active ? 'Running' : (data.status || 'Stopped');
since.textContent = data.since ? 'since ' + data.since : '';
svc.textContent = data.service ? data.service + '.service' : '';
}
async function loadStatus() {
try {
const r = await fetch('/api/botcontrol?action=status&project_id=' + pid);
const d = await r.json();
if (d.error) { txt.textContent = d.error; return; }
setStatus(d);
} catch (e) { txt.textContent = 'Error loading status'; }
}
async function runAction(action) {
outputWrap.classList.remove('d-none');
output.textContent = action + '…';
try {
const r = await fetch('/api/botcontrol', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action }),
});
const d = await r.json();
output.textContent = d.output || (d.ok ? 'Done.' : 'Failed.');
setStatus(d);
} catch (e) { output.textContent = 'Request failed: ' + e.message; }
}
document.getElementById('botStartBtn').addEventListener('click', () => runAction('start'));
document.getElementById('botStopBtn').addEventListener('click', () => runAction('stop'));
document.getElementById('botRestartBtn').addEventListener('click', () => runAction('restart'));
document.getElementById('botRefreshStatusBtn').addEventListener('click', loadStatus);
document.getElementById('botClearOutputBtn').addEventListener('click', () => {
output.textContent = '';
outputWrap.classList.add('d-none');
});
loadStatus();
})();
// ── Bot: config editor ────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botConfigPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
function rgbToHex([r, g, b]) {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
let _config = null;
async function loadConfig() {
const r = await fetch('/api/botconfig?action=get&project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
_config = d.config;
renderStreamers(_config.twitch || {});
renderFeeds(_config.rss || {});
renderMastodon(_config.mastodon || {});
renderLinkedinPages(_config.linkedin?.pages || {});
renderLinkedinConnection(_config.linkedin || {});
}
// ── Streamers ────────────────────────────────────────────────────────────────
function renderStreamers(twitch) {
const list = document.getElementById('streamerList');
const names = Object.keys(twitch);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No streamers configured.</div>'; return; }
list.innerHTML = names.map(name => {
const s = twitch[name];
const hex = rgbToHex(s.color || [255, 255, 255]);
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="rounded-circle border flex-shrink-0" style="width:16px;height:16px;background:${hex}"></span>
<span class="fw-semibold small">${esc(name)}</span>
<small class="text-muted ms-1">ch: <code>${esc(String(s.channel_id ?? ''))}</code></small>
${s.role_id ? `<small class="text-muted">role: <code>${esc(String(s.role_id))}</code></small>` : ''}
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-streamer-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-streamer-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.edit-streamer-btn').forEach(b => b.addEventListener('click', () => openStreamerModal('edit', b.dataset.name)));
list.querySelectorAll('.del-streamer-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_streamer', b.dataset.name, `Remove streamer "${b.dataset.name}"?`, 'Streamer removed')));
}
function openStreamerModal(mode, name = '') {
const s = (mode === 'edit' && _config?.twitch?.[name]) || {};
document.getElementById('streamerModalTitle').textContent = mode === 'add' ? 'Add Streamer' : 'Edit Streamer';
document.getElementById('streamerModalMode').value = mode;
document.getElementById('streamerName').value = name;
document.getElementById('streamerName').disabled = mode === 'edit';
document.getElementById('streamerChannelId').value = s.channel_id ?? '';
document.getElementById('streamerRoleId').value = s.role_id ?? '';
document.getElementById('streamerColor').value = rgbToHex(s.color || [100, 65, 165]);
document.getElementById('streamerModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).show();
}
document.getElementById('addStreamerBtn').addEventListener('click', () => openStreamerModal('add'));
document.getElementById('streamerModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('streamerModalMode').value;
const name = document.getElementById('streamerName').value.trim();
const errEl = document.getElementById('streamerModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_streamer' : 'save_streamer', name, data: {
channel_id: document.getElementById('streamerChannelId').value.trim(),
role_id: document.getElementById('streamerRoleId').value.trim(),
color: hexToRgb(document.getElementById('streamerColor').value),
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).hide(); showSuccess('Streamer saved'); loadConfig(); }
});
// ── RSS feeds ────────────────────────────────────────────────────────────────
function renderFeeds(rss) {
const list = document.getElementById('rssList');
const names = Object.keys(rss);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No RSS feeds configured.</div>'; return; }
list.innerHTML = names.map(name => {
const f = rss[name];
const hex = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]);
const activeBadge = f.active
? '<span class="badge bg-success-subtle text-success border ms-1">active</span>'
: '<span class="badge bg-secondary-subtle text-muted border ms-1">paused</span>';
const acct = f.mastodon_account ? `<small class="text-muted"><i class="bi bi-mastodon"></i> ${esc(f.mastodon_account)}</small>` : '';
const liPage = f.linkedin_page ? `<small class="text-muted"><i class="bi bi-linkedin"></i> ${esc(f.linkedin_page)}</small>` : '';
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1 flex-wrap">
<span class="rounded-circle border flex-shrink-0" style="width:16px;height:16px;background:${hex}"></span>
<span class="fw-semibold small">${esc(name)}</span>${activeBadge}
${acct}${liPage}
<small class="text-muted text-truncate" style="max-width:180px" title="${esc(f.rss_url || '')}">${esc(f.rss_url || '')}</small>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 repost-rss-btn" data-name="${esc(name)}" title="Repeat last post"><i class="bi bi-arrow-repeat"></i></button>
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-rss-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-rss-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.repost-rss-btn').forEach(b => b.addEventListener('click', () => {
const name = b.dataset.name;
confirmAction(`Repeat last post for feed "${name}"?\nThe bot will repost it on the next poll (within 5 min).`, async () => {
const r = await fetch('/api/botcompose', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'rss_repost', feed_name: name }),
});
const d = await r.json();
if (d.ok) showSuccess(d.message || 'Repost queued — bot will post on next poll');
else showError(d.error || 'Failed');
});
}));
list.querySelectorAll('.edit-rss-btn').forEach(b => b.addEventListener('click', () => openRssModal('edit', b.dataset.name)));
list.querySelectorAll('.del-rss-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_rss', b.dataset.name, `Remove RSS feed "${b.dataset.name}"?`, 'Feed removed')));
}
function populateSelect(id, options, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '<option value="">— none —</option>' +
options.map(([v, l]) => `<option value="${esc(v)}" ${v === selected ? 'selected' : ''}>${esc(l)}</option>`).join('');
}
function openRssModal(mode, name = '') {
const f = (mode === 'edit' && _config?.rss?.[name]) || {};
document.getElementById('rssModalTitle').textContent = mode === 'add' ? 'Add RSS Feed' : 'Edit RSS Feed';
document.getElementById('rssModalMode').value = mode;
document.getElementById('rssName').value = name;
document.getElementById('rssName').disabled = mode === 'edit';
document.getElementById('rssUrl').value = f.rss_url ?? '';
document.getElementById('rssChannelId').value = f.channel_id ?? '';
document.getElementById('rssRoleId').value = f.role_id ?? '';
document.getElementById('rssColor').value = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]);
document.getElementById('rssActive').checked = f.active !== false;
populateSelect('rssMastodonAccount',
Object.entries(_config?.mastodon || {}).map(([k, v]) => [k, v.label || k]),
f.mastodon_account || '');
populateSelect('rssLinkedinPage',
Object.entries(_config?.linkedin?.pages || {}).map(([k, v]) => [k, v.label || k]),
f.linkedin_page || '');
document.getElementById('rssModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).show();
}
document.getElementById('addRssBtn').addEventListener('click', () => openRssModal('add'));
document.getElementById('rssModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('rssModalMode').value;
const name = document.getElementById('rssName').value.trim();
const errEl = document.getElementById('rssModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_rss' : 'save_rss', name, data: {
rss_url: document.getElementById('rssUrl').value.trim(),
channel_id: document.getElementById('rssChannelId').value.trim(),
role_id: document.getElementById('rssRoleId').value.trim(),
color: hexToRgb(document.getElementById('rssColor').value),
active: document.getElementById('rssActive').checked,
mastodon_account: document.getElementById('rssMastodonAccount').value,
linkedin_page: document.getElementById('rssLinkedinPage').value,
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).hide(); showSuccess('Feed saved'); loadConfig(); }
});
// ── Mastodon accounts ─────────────────────────────────────────────────────────
function renderMastodon(mastodon) {
const list = document.getElementById('mastodonList');
const names = Object.keys(mastodon);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No accounts configured.</div>'; return; }
list.innerHTML = names.map(name => {
const a = mastodon[name];
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="fw-semibold small">${esc(a.label || name)}</span>
<code class="small text-muted">MASTODON_TOKEN_${esc(name.toUpperCase())}</code>
<small class="text-muted text-truncate" style="max-width:160px">${esc(a.api_base_url || '')}</small>
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 compose-mastodon-btn"
data-name="${esc(name)}" data-label="${esc(a.label || name)}" title="Post to Mastodon">
<i class="bi bi-pencil-square"></i></button>
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-mastodon-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-mastodon-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.compose-mastodon-btn').forEach(b => b.addEventListener('click', () => openComposeModal('mastodon', b.dataset.name, b.dataset.label)));
list.querySelectorAll('.edit-mastodon-btn').forEach(b => b.addEventListener('click', () => openMastodonModal('edit', b.dataset.name)));
list.querySelectorAll('.del-mastodon-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_mastodon', b.dataset.name, `Remove account "${b.dataset.name}"?`, 'Account removed')));
}
function openMastodonModal(mode, name = '') {
const a = (mode === 'edit' && _config?.mastodon?.[name]) || {};
document.getElementById('mastodonModalTitle').textContent = mode === 'add' ? 'Add Mastodon Account' : 'Edit Mastodon Account';
document.getElementById('mastodonModalMode').value = mode;
document.getElementById('mastodonName').value = name;
document.getElementById('mastodonName').disabled = mode === 'edit';
document.getElementById('mastodonLabel').value = a.label ?? '';
document.getElementById('mastodonBase').value = a.api_base_url ?? '';
document.getElementById('mastodonModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).show();
}
document.getElementById('addMastodonBtn').addEventListener('click', () => openMastodonModal('add'));
document.getElementById('mastodonModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('mastodonModalMode').value;
const name = document.getElementById('mastodonName').value.trim();
const errEl = document.getElementById('mastodonModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_mastodon' : 'save_mastodon', name, data: {
label: document.getElementById('mastodonLabel').value.trim(),
api_base_url: document.getElementById('mastodonBase').value.trim(),
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).hide(); showSuccess('Account saved'); loadConfig(); }
});
// ── LinkedIn pages ────────────────────────────────────────────────────────────
function renderLinkedinConnection(li) {
const label = document.getElementById('liConnectionLabel');
const expiry = document.getElementById('liTokenExpiry');
const connected = li.access_token === '***';
label.textContent = connected ? 'Connected to LinkedIn' : 'Not connected';
label.className = 'small fw-semibold ' + (connected ? 'text-success' : 'text-muted');
expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : '';
}
document.getElementById('liConnectBtn').addEventListener('click', async () => {
const r = await fetch('/api/linkedin_auth?project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
window.location.href = d.url;
});
function renderLinkedinPages(pages) {
const list = document.getElementById('linkedinPagesList');
const names = Object.keys(pages);
if (!names.length) { list.innerHTML = '<div class="text-muted small">No pages configured.</div>'; return; }
list.innerHTML = names.map(name => {
const p = pages[name];
const isPersonal = p.type === 'personal';
const badge = isPersonal
? `<span class="badge bg-secondary fw-normal">Personal</span>`
: `<code class="small text-muted">${esc(p.organization_id || '')}</code>`;
return `<div class="d-flex align-items-center gap-2 border rounded p-2 mb-1">
<span class="fw-semibold small">${esc(p.label || name)}</span>
${badge}
<div class="ms-auto d-flex gap-1">
<button class="btn btn-xs btn-outline-secondary py-0 px-1 compose-linkedin-btn"
data-name="${esc(name)}" data-label="${esc(p.label || name)}" title="Post to LinkedIn">
<i class="bi bi-pencil-square"></i></button>
<button class="btn btn-xs btn-outline-secondary py-0 px-1 edit-lipage-btn" data-name="${esc(name)}"><i class="bi bi-pencil"></i></button>
<button class="btn btn-xs btn-outline-danger py-0 px-1 del-lipage-btn" data-name="${esc(name)}"><i class="bi bi-trash"></i></button>
</div>
</div>`;
}).join('');
list.querySelectorAll('.compose-linkedin-btn').forEach(b => b.addEventListener('click', () => openComposeModal('linkedin', b.dataset.name, b.dataset.label)));
list.querySelectorAll('.edit-lipage-btn').forEach(b => b.addEventListener('click', () => openLinkedinPageModal('edit', b.dataset.name)));
list.querySelectorAll('.del-lipage-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_linkedin_page', b.dataset.name, `Remove page "${b.dataset.name}"?`, 'Page removed')));
}
function _liPageTypeToggle() {
const isPersonal = document.getElementById('linkedinPageType').value === 'personal';
document.getElementById('linkedinPageOrgIdRow').classList.toggle('d-none', isPersonal);
}
function openLinkedinPageModal(mode, name = '') {
const p = (mode === 'edit' && _config?.linkedin?.pages?.[name]) || {};
document.getElementById('linkedinPageModalTitle').textContent = mode === 'add' ? 'Add LinkedIn Page' : 'Edit LinkedIn Page';
document.getElementById('linkedinPageModalMode').value = mode;
document.getElementById('linkedinPageName').value = name;
document.getElementById('linkedinPageName').disabled = mode === 'edit';
document.getElementById('linkedinPageLabel').value = p.label ?? '';
document.getElementById('linkedinPageType').value = p.type ?? 'organization';
document.getElementById('linkedinPageOrgId').value = p.organization_id ?? '';
document.getElementById('linkedinPageModalError').classList.add('d-none');
_liPageTypeToggle();
bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).show();
}
document.getElementById('linkedinPageType').addEventListener('change', _liPageTypeToggle);
document.getElementById('addLinkedinPageBtn').addEventListener('click', () => openLinkedinPageModal('add'));
document.getElementById('linkedinPageModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('linkedinPageModalMode').value;
const name = document.getElementById('linkedinPageName').value.trim();
const type = document.getElementById('linkedinPageType').value;
const errEl = document.getElementById('linkedinPageModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_linkedin_page' : 'save_linkedin_page', name, data: {
label: document.getElementById('linkedinPageLabel').value.trim(),
type,
organization_id: type === 'organization' ? document.getElementById('linkedinPageOrgId').value.trim() : '',
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).hide(); showSuccess('Page saved'); loadConfig(); }
});
// ── Compose (Mastodon / LinkedIn direct post) ─────────────────────────────────
function openComposeModal(type, name, label) {
document.getElementById('composeModalTitle').textContent = 'Post to ' + label;
document.getElementById('composeModalType').value = type;
document.getElementById('composeModalName').value = name;
document.getElementById('composeText').value = '';
document.getElementById('composeModalError').classList.add('d-none');
document.getElementById('composeHint').textContent = type === 'mastodon'
? 'Plain text post — no image upload from this composer.'
: 'Text post to LinkedIn organization page.';
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).show();
setTimeout(() => document.getElementById('composeText').focus(), 300);
}
document.getElementById('composeModalPostBtn').addEventListener('click', async () => {
const type = document.getElementById('composeModalType').value;
const name = document.getElementById('composeModalName').value;
const text = document.getElementById('composeText').value.trim();
const errEl = document.getElementById('composeModalError');
errEl.classList.add('d-none');
if (!text) { errEl.textContent = 'Text is required'; errEl.classList.remove('d-none'); return; }
const body = { project_id: +pid, action: type === 'mastodon' ? 'mastodon_post' : 'linkedin_post', text };
if (type === 'mastodon') body.account_name = name;
else body.page_name = name;
const btn = document.getElementById('composeModalPostBtn');
btn.disabled = true;
const r = await fetch('/api/botcompose', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
btn.disabled = false;
if (d.ok) {
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).hide();
showSuccess('Posted!');
} else {
errEl.textContent = d.error || 'Post failed';
errEl.classList.remove('d-none');
}
});
// ── Shared helpers ────────────────────────────────────────────────────────────
async function apiPost(body, errEl) {
const r = await fetch('/api/botconfig', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, ...body }),
});
const d = await r.json();
if (d.error && errEl) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return false; }
if (d.error) { showError(d.error); return false; }
return true;
}
function deleteItem(action, name, confirmMsg, successMsg) {
confirmAction(confirmMsg, async () => {
const ok = await apiPost({ action, name }, null);
if (ok) { showSuccess(successMsg); loadConfig(); }
});
}
loadConfig();
})();
// ── Bot: log viewer ───────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botLogsPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
const pre = document.getElementById('logsOutput');
let _timer = null;
async function loadLogs() {
const lines = document.getElementById('logsLineCount').value;
try {
const r = await fetch(`/api/botlogs?project_id=${pid}&lines=${lines}`);
const d = await r.json();
if (d.error) { pre.textContent = d.error; return; }
pre.textContent = d.lines.join('\n') || '(no log entries)';
pre.scrollTop = pre.scrollHeight;
} catch (e) { pre.textContent = 'Error: ' + e.message; }
}
function startAutoRefresh() {
stopAutoRefresh();
_timer = setInterval(loadLogs, 5000);
}
function stopAutoRefresh() {
if (_timer) { clearInterval(_timer); _timer = null; }
}
document.getElementById('logsRefreshBtn').addEventListener('click', loadLogs);
document.getElementById('logsLineCount').addEventListener('change', loadLogs);
document.getElementById('logsAutoRefresh').addEventListener('change', e => {
e.target.checked ? startAutoRefresh() : stopAutoRefresh();
});
loadLogs();
})();