'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 = `
`;
box.appendChild(t);
bootstrap.Toast.getOrCreateInstance(t, { delay: 4000 }).show();
t.addEventListener('hidden.bs.toast', () => t.remove());
}
const showError = msg => showToast(msg, 'danger');
const showSuccess = msg => showToast(msg, 'success');
// ── Confirm modal (no browser dialogs) ───────────────────────────────────────
// The modal is a single shared element. Earlier versions left the previous
// confirm-handler attached when the user cancelled, so a second call would
// add a *second* listener — clicking Confirm then ran the cancelled call's
// callback. This wires both confirm and the modal's hidden event each time
// and tears them down regardless of how the modal closes.
function confirmAction(msg, cb) {
let modal = document.getElementById('_confirmModal');
if (!modal) {
modal = document.createElement('div');
modal.className = 'modal fade'; modal.id = '_confirmModal';
modal.innerHTML = ``;
document.body.appendChild(modal);
}
document.getElementById('_confirmMsg').textContent = msg;
const bsM = bootstrap.Modal.getOrCreateInstance(modal);
const ok = document.getElementById('_confirmOk');
let confirmed = false;
const onConfirm = () => { confirmed = true; bsM.hide(); };
const onHidden = () => {
ok.removeEventListener('click', onConfirm);
modal.removeEventListener('hidden.bs.modal', onHidden);
if (confirmed) cb();
};
ok.addEventListener('click', onConfirm);
modal.addEventListener('hidden.bs.modal', onHidden);
bsM.show();
}
// ── Utilities ─────────────────────────────────────────────────────────────────
function esc(s) {
return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
}
function fmtSize(b) {
if (b == null) return '';
if (b < 1024) return b + ' B';
if (b < 1024 * 1024) return (b / 1024).toFixed(1) + ' KB';
return (b / 1024 / 1024).toFixed(1) + ' MB';
}
function modeForFile(name) {
const ext = (name.split('.').pop() || '').toLowerCase();
return { js:'javascript', ts:'javascript', json:'javascript', css:'css',
php:'php', html:'htmlmixed', htm:'htmlmixed', xml:'xml', svg:'xml',
md:'markdown', markdown:'markdown', yml:'yaml', yaml:'yaml',
sh:'shell', bash:'shell' }[ext] || null;
}
function isImage(name) { return /\.(jpe?g|png|gif|webp|svg)$/i.test(name); }
function isVideo(name) { return /\.(mp4|webm|mov)$/i.test(name); }
function isAudio(name) { return /\.(mp3|wav|ogg|flac)$/i.test(name); }
// ── Dashboard: add project ────────────────────────────────────────────────────
const addProjectForm = document.getElementById('addProjectForm');
if (addProjectForm) {
addProjectForm.addEventListener('submit', async e => {
e.preventDefault();
const err = document.getElementById('addProjectError');
err.classList.add('d-none');
const data = Object.fromEntries(new FormData(addProjectForm));
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data),
});
const json = await res.json();
if (json.ok) { location.reload(); }
else { err.textContent = json.error || 'Error'; err.classList.remove('d-none'); }
});
}
// ── Project: delete ───────────────────────────────────────────────────────────
const confirmDelete = document.getElementById('confirmDelete');
if (confirmDelete) {
confirmDelete.addEventListener('click', async () => {
const id = parseInt(confirmDelete.dataset.projectId);
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', id }),
});
const json = await res.json();
if (json.ok) location.href = '/';
else showError(json.error || 'Error');
});
}
// ── File editor — tabbed pane system ─────────────────────────────────────────
// Falls back to modal when no tab bar exists (e.g. posts pane)
let _tabCounter = 0;
const _tabs = []; // [{id, pid, path, name, kind}] kind: 'editor'|'preview'
let _activeTabId = null;
const _tabCMs = {}; // id → CodeMirror instance
function _isMediaFile(name) {
return /\.(jpe?g|png|gif|webp|svg|mp4|webm|mp3|wav|ogg|flac|pdf)$/i.test(name);
}
function _mediaKind(name) {
if (/\.(jpe?g|png|gif|webp|svg)$/i.test(name)) return 'image';
if (/\.(mp4|webm)$/i.test(name)) return 'video';
if (/\.(mp3|wav|ogg|flac)$/i.test(name)) return 'audio';
if (/\.pdf$/i.test(name)) return 'pdf';
return null;
}
function openFileEditor(pid, path, name) {
const tabBar = document.getElementById('editorTabBar');
const tabContent = document.getElementById('editorTabContent');
const browser = document.getElementById('fileBrowser');
const isStorage = browser?.dataset.projectType === 'storage';
// ── Files tab: full tab system ────────────────────────────────────────────
if (tabBar && tabContent) {
// Storage images → gallery overlay instead of pane preview
if (isStorage && _mediaKind(name) === 'image') {
_showGalleryOverlay(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`, name);
return;
}
// Already open? Switch to it.
const existing = _tabs.find(t => t.pid === pid && t.path === path);
if (existing) { _switchTab(existing.id); return; }
// Media preview tab
if (_isMediaFile(name)) {
const id = 'tab' + (++_tabCounter);
_tabs.push({ id, pid, path, name, kind: 'preview' });
_renderTabBar();
const div = document.createElement('div');
div.id = 'tc-' + id;
div.style.display = 'none';
div.style.height = '100%';
div.innerHTML = _buildPreviewHTML(pid, path, name);
tabContent.appendChild(div);
_switchTab(id);
return;
}
// Text editor tab
fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`)
.then(r => r.json())
.then(data => {
if (data.error) { showError(data.error); return; }
const isMd = /\.(md|markdown)$/i.test(name);
const isDraft = path.startsWith('source/_drafts/');
if (isMd) { _openMdEditorTab(pid, path, name, data.content ?? '', isDraft); return; }
const id = 'tab' + (++_tabCounter);
_tabs.push({ id, pid, path, name, kind: 'editor', isDraft, dirty: false });
_renderTabBar();
const div = document.createElement('div');
div.id = 'tc-' + id;
div.style.display = 'none';
div.style.height = '100%';
div.style.position = 'relative';
div.innerHTML = `
Save
`;
tabContent.appendChild(div);
const cm = CodeMirror(div.querySelector('#paneCm-' + id), {
value: data.content ?? '', mode: modeForFile(name), theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
extraKeys: { 'Ctrl-S': () => _paneTabSave(id), 'Cmd-S': () => _paneTabSave(id) },
});
cm.on('change', () => _markDirty(id));
_tabCMs[id] = cm;
div.querySelector('#paneSaveBtn-' + id).addEventListener('click', () => _paneTabSave(id));
// Ctrl/Cmd+S anywhere in the pane saves.
div.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 's' || e.key === 'S')) {
e.preventDefault();
_paneTabSave(id);
}
});
_switchTab(id);
})
.catch(err => showError('Error: ' + err.message));
return;
}
// ── Fallback: modal ───────────────────────────────────────────────────────
fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`)
.then(r => r.json())
.then(data => {
if (data.error) { showError(data.error); return; }
_renderModalEditor(pid, path, name, data.content ?? '');
})
.catch(err => showError('Error: ' + err.message));
}
function _buildPreviewHTML(pid, path, name) {
const src = `/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=serve`;
const mk = _mediaKind(name);
if (mk === 'image') return `${esc(name)}
`;
if (mk === 'pdf') return ``;
if (mk === 'video') return ``;
if (mk === 'audio') return ``;
return ``;
}
function _showGalleryOverlay(src, name) {
let ov = document.getElementById('_galleryOverlay');
if (!ov) {
ov = document.createElement('div');
ov.id = '_galleryOverlay';
ov.className = 'gallery-overlay';
ov.innerHTML = `
`;
ov.addEventListener('click', () => ov.remove());
document.body.appendChild(ov);
}
document.getElementById('_galleryImg').src = src;
document.getElementById('_galleryCaption').textContent = name;
if (!document.body.contains(ov)) document.body.appendChild(ov);
}
function _renderTabBar() {
const bar = document.getElementById('editorTabBar');
if (!bar) { _saveTabState(); return; }
if (!_tabs.length) { bar.innerHTML = ''; _saveTabState(); return; }
bar.innerHTML = _tabs.map(t => {
const cmDirty = t.kind === 'editor' && _tabCMs[t.id]?.isClean() === false;
const dirty = (t.dirty || cmDirty) ? '● ' : '';
const icon = t.kind === 'preview' ? ' ' : '';
return `
${icon}${esc(t.name)} ${dirty}
✕
`;
}).join('');
bar.querySelectorAll('.editor-tab').forEach(el => {
el.addEventListener('click', ev => {
if (ev.target.closest('[data-close-tab]')) return;
_switchTab(el.dataset.tabId);
});
});
bar.querySelectorAll('[data-close-tab]').forEach(el => {
el.addEventListener('click', () => _closeTab(el.dataset.closeTab));
});
_saveTabState();
}
function _tabStorageKey() {
const fb = document.getElementById('fileBrowser');
const pp = document.getElementById('postsPanel');
const el = fb || pp;
if (!el) return null;
return 'hackmancms_tabs_' + el.dataset.projectId + '_' + (fb ? 'files' : 'posts');
}
function _saveTabState() {
const key = _tabStorageKey();
if (!key) return;
const items = _tabs
.filter(t => t.path && (t.kind === 'editor' || t.kind === 'preview'))
.map(t => ({ pid: t.pid, path: t.path, name: t.name, kind: t.kind }));
if (!items.length) localStorage.removeItem(key);
else localStorage.setItem(key, JSON.stringify({ tabs: items, active: _activeTabId }));
}
function _restoreTabState() {
const key = _tabStorageKey();
if (!key) return;
let saved;
try { saved = JSON.parse(localStorage.getItem(key) || 'null'); } catch (e) { saved = null; }
if (!saved?.tabs?.length) return;
saved.tabs.forEach(t => openFileEditor(t.pid, t.path, t.name));
}
function _switchTab(id) {
_tabs.forEach(t => {
const div = document.getElementById('tc-' + t.id);
if (div) div.style.display = 'none';
});
const div = document.getElementById('tc-' + id);
if (div) {
div.style.display = 'flex';
div.style.flexDirection = 'column';
div.style.height = '100%';
requestAnimationFrame(() => _tabCMs[id]?.refresh());
}
_activeTabId = id;
_renderTabBar();
// Hide placeholder
const content = document.getElementById('editorTabContent');
const placeholder = content?.querySelector('.editor-placeholder');
if (placeholder) placeholder.style.display = 'none';
}
function _closeTab(id) {
const idx = _tabs.findIndex(t => t.id === id);
if (idx === -1) return;
if (_tabCMs[id]) { try { _tabCMs[id].toTextArea(); } catch(e) {} delete _tabCMs[id]; }
delete _tabMdMounts[id];
if (_tabMdReflowers[id]) {
try { _tabMdReflowers[id].disconnect(); } catch (e) {}
delete _tabMdReflowers[id];
}
if (_tabImgObservers[id]) {
try { _tabImgObservers[id].disconnect(); } catch (e) {}
delete _tabImgObservers[id];
}
document.getElementById('tc-' + id)?.remove();
_tabs.splice(idx, 1);
if (_activeTabId === id) {
const next = _tabs[Math.min(idx, _tabs.length - 1)];
if (next) { _switchTab(next.id); }
else {
_activeTabId = null;
const content = document.getElementById('editorTabContent');
if (content) {
const ph = content.querySelector('.editor-placeholder');
if (ph) ph.style.display = '';
else content.innerHTML = ` Select a file to edit
`;
}
}
}
_renderTabBar();
}
function _markDirty(id) {
const tab = _tabs.find(t => t.id === id);
if (tab) tab.dirty = true;
_renderTabBar();
_updateSaveBtnState(id);
}
// ── Markdown editor (Milkdown via Agenda-style mk-mount) ────────────────────
// Each .md tab gets a textarea.mk-mount whose value is the body markdown
// (image URLs pre-rewritten to served URLs); milkdown-mount.js wraps it in
// a WYSIWYG editor with a built-in MD/WYSIWYG toolbar toggle.
const _tabMdMounts = {}; // id → textarea handle (.mkMount)
const _tabFmCMs = {}; // id → CodeMirror (front matter YAML)
const _tabMdReflowers = {}; // id → ResizeObserver
// Walk the rendered ProseMirror DOM and rewrite each 's `src` to the
// served URL — without touching the editor's underlying model. The model
// keeps the authored path (e.g. `images/foo.png`), so getMarkdown() returns
// clean markdown on save and Hexo sees exactly what the user wrote.
const _tabImgObservers = {};
function _rewriteImgsInPlace(root, pid) {
if (!root) return;
root.querySelectorAll('img').forEach(img => {
const cur = img.getAttribute('src') || '';
if (!cur) return;
if (cur.includes('/api/files')) return; // already rewritten
if (/^(https?:|data:|\/\/)/i.test(cur)) return; // absolute, leave as-is
const resolved = _resolveImagePathForEditor(cur, pid);
if (resolved !== cur) {
img.setAttribute('data-original-src', cur);
img.setAttribute('src', resolved);
}
});
}
function _attachImgSrcRewriter(id, pid) {
const root = document.getElementById('tc-' + id);
const pm = root?.querySelector('.ProseMirror');
if (!pm) return;
// Initial pass for whatever's already rendered
_rewriteImgsInPlace(pm, pid);
// Watch for newly added or changed elements (paste, image insert,
// ProseMirror re-render). Filtering attributeFilter to `src` avoids loops
// when our own setAttribute fires a notification.
const obs = new MutationObserver(() => _rewriteImgsInPlace(pm, pid));
obs.observe(pm, { childList: true, subtree: true,
attributes: true, attributeFilter: ['src'] });
// Replace any existing observer (e.g. on re-mount)
if (_tabImgObservers[id]) try { _tabImgObservers[id].disconnect(); } catch (e) {}
_tabImgObservers[id] = obs;
}
function _reflowMdEditor(id) {
const mount = document.getElementById('paneEditor-' + id);
if (!mount) return;
const stackH = mount.clientHeight;
if (stackH < 1) return;
// Editor mount may not be present yet (mk-mount wraps the textarea async).
const wrap = mount.querySelector('.mk-mount-wrap');
if (!wrap) return;
const body = wrap.querySelector('.ie-mk-body');
if (!body) return;
const wrapToolbar = wrap.querySelector('.ie-mk-toolbar');
const innerPhotos = body.querySelector('.md-photos-banner');
const toolbarH = wrapToolbar ? wrapToolbar.offsetHeight : 40;
const photosH = innerPhotos && !innerPhotos.classList.contains('d-none')
? innerPhotos.offsetHeight : 0;
const wrapBd = 2;
// Lock wrap and body to definite pixel heights — ProseMirror's percentage
// min-height doesn't cascade reliably through Milkdown's wrappers, so we
// size them manually to make body's overflow-y: auto fire dependably.
wrap.style.flex = '0 0 auto';
wrap.style.height = stackH + 'px';
const bodyH = Math.max(80, stackH - toolbarH - wrapBd);
body.style.flex = '0 0 auto';
body.style.height = bodyH + 'px';
const editorMinH = Math.max(60, bodyH - photosH);
const milkdownRoot = body.querySelector(':scope > div:not(.md-photos-banner)');
if (milkdownRoot) milkdownRoot.style.minHeight = editorMinH + 'px';
body.querySelectorAll('.milkdown, .editor, .ProseMirror').forEach(el => {
el.style.minHeight = editorMinH + 'px';
});
const taSrc = body.querySelector('textarea.ie-mk-ta');
if (taSrc) {
taSrc.style.minHeight = editorMinH + 'px';
taSrc.style.height = editorMinH + 'px';
}
}
async function _openMdEditorTab(pid, path, name, content, isDraft) {
const tabContent = document.getElementById('editorTabContent');
if (!tabContent) return;
const id = 'tab' + (++_tabCounter);
const { fm, body, photos } = _parseMdSource(content);
const hadFm = content.startsWith('---');
const isHexo = path.startsWith('source/_posts/') || path.startsWith('source/_drafts/');
const useFmBlock = hadFm || isHexo;
_tabs.push({ id, pid, path, name, kind: 'md-editor', isDraft, dirty: false });
_renderTabBar();
const div = document.createElement('div');
div.id = 'tc-' + id;
div.style.display = 'none';
div.style.height = '100%';
div.style.position = 'relative';
div.innerHTML = `
Save
`;
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
? `
Publish
`
: '';
const wrap = document.createElement('div');
wrap.className = 'dropdown mk-pane-kebab ms-1';
wrap.innerHTML = `
`;
tb.appendChild(wrap);
wrap.querySelector('#paneDeleteBtn-' + id).addEventListener('click', () => _paneTabDelete(id));
wrap.querySelector('#paneRenameBtn-' + id).addEventListener('click', () => {
promptRename(pid, path, newPath => {
const tab = _tabs.find(t => t.id === id);
if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); }
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
});
});
wrap.querySelector('#paneMoveBtn-' + id).addEventListener('click', () => {
promptMove(pid, path, newPath => {
const tab = _tabs.find(t => t.id === id);
if (tab) { tab.path = newPath; _renderTabBar(); }
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
});
});
if (isDraft) {
wrap.querySelector('#panePublishBtn-' + id)?.addEventListener('click', () => _paneTabPublish(id, pid, path));
}
}
function _updateSaveBtnState(id) {
const tab = _tabs.find(t => t.id === id);
const btn = document.getElementById('paneSaveBtn-' + id);
if (!tab || !btn) return;
btn.classList.toggle('d-none', !tab.dirty);
}
// Parse a markdown source into { fm: yamlString, body: string, photos: string[] }
function _parseMdSource(text) {
const out = { fm: '', body: text, photos: [] };
if (!text.startsWith('---')) return out;
const end = text.indexOf('\n---', 3);
if (end === -1) return out;
out.fm = text.substring(3, end).replace(/^\n/, '').replace(/\n$/, '');
out.body = text.substring(end + 4).replace(/^\s*\n/, '');
// photos: inline array → photos: [a, b, c]
const inline = out.fm.match(/^photos:\s*\[(.+?)\]\s*$/m);
if (inline) {
out.photos = inline[1].split(',').map(s => s.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
} else {
// photos: block list → photos:\n - a\n - b
const block = out.fm.match(/^photos:\s*\n((?:[ \t]*-\s*.+\n?)+)/m);
if (block) {
out.photos = [...block[1].matchAll(/^[ \t]*-\s*(.+?)\s*$/gm)]
.map(m => m[1].trim().replace(/^["']|["']$/g, ''))
.filter(Boolean);
} else {
// photos: single value on the same line → photos: foo.jpg
const single = out.fm.match(/^photos:\s*([^\s\[].*?)\s*$/m);
if (single) out.photos = [single[1].replace(/^["']|["']$/g, '')];
}
}
return out;
}
function _renderPhotosBanner(el, photos, pid) {
if (!el) return;
if (!photos.length) { el.classList.add('d-none'); el.innerHTML = ''; return; }
el.classList.remove('d-none');
el.innerHTML = photos.map(p => {
const url = _resolveImagePathForEditor(p, pid);
return ` `;
}).join('');
}
// images/foo.png → /api/files?project_id=X&action=serve&path=source/images/foo.png
// Already-absolute or already-rewritten URLs pass through unchanged.
function _resolveImagePathForEditor(src, pid) {
if (!src) return src;
if (/^(https?:|data:|\/\/)/i.test(src)) return src;
if (src.includes('/api/files')) return src;
let rel = src.replace(/^\.?\//, '');
if (!rel.startsWith('source/')) rel = 'source/' + rel;
return `/api/files?project_id=${pid}&action=serve&path=${encodeURIComponent(rel)}`;
}
// Rewrite ![]() image URLs for in-editor display.
function _rewriteImagePathsForEditor(markdown, pid) {
return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) =>
pre + _resolveImagePathForEditor(src, pid) + post);
}
// Reverse: strip the /api/files prefix back to the original relative form.
function _reverseRewriteImagePaths(markdown, pid) {
const prefix = `/api/files?project_id=${pid}&action=serve&path=`;
return markdown.replace(/(!\[[^\]]*\]\()([^)\s]+)([^)]*\))/g, (m, pre, src, post) => {
if (src.startsWith(prefix)) {
let p = decodeURIComponent(src.substring(prefix.length));
if (p.startsWith('source/')) p = p.substring(7);
return pre + p + post;
}
return m;
});
}
async function _paneTabSave(id) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
const status = document.getElementById('paneStatus-' + id);
let content;
if (tab.kind === 'md-editor') {
const mount = _tabMdMounts[id];
if (!mount?.mkMount) { showError('Editor not ready'); return; }
const md = mount.mkMount.getContent() ?? '';
// FM lives as the first ```yaml block; extract it and convert to ---\n...\n---
const fmMatch = md.match(/^```yaml\r?\n([\s\S]*?)\n?```(\r?\n|$)/);
if (fmMatch) {
const fm = fmMatch[1];
const body = md.substring(fmMatch[0].length).replace(/^\s*\n/, '');
content = '---\n' + fm + '\n---\n\n' + body;
} else {
content = md;
}
} else {
if (!_tabCMs[id]) return;
content = _tabCMs[id].getValue();
}
if (status) status.textContent = 'Saving…';
const res = await fetch('/api/files?project_id=' + tab.pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'write', path: tab.path, content }),
});
const data = await res.json();
if (data.ok) {
tab.dirty = false;
if (tab.kind !== 'md-editor') _tabCMs[id]?.markClean();
if (status) status.textContent = 'Saved.';
showSuccess('Saved');
_renderTabBar();
_updateSaveBtnState(id);
} else {
if (status) status.textContent = '';
showError(data.error || 'Save failed');
}
}
async function _paneTabDelete(id) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
confirmAction(`Delete "${tab.name}"? This cannot be undone.`, async () => {
const res = await fetch('/api/files?project_id=' + tab.pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', path: tab.path }),
});
const data = await res.json();
if (data.ok) {
showSuccess('Deleted ' + tab.name);
_closeTab(id);
const browser = document.getElementById('fileBrowser');
if (browser) {
const currentPath = browser.dataset.currentPath || '';
if (typeof loadDir === 'function') loadDir(currentPath);
}
if (document.getElementById('postsPanel')) _postsLoadFn(window._postsCurrentType || 'post');
} else {
showError(data.error || 'Delete failed');
}
});
}
async function _paneTabPublish(id, pid, path) {
const tab = _tabs.find(t => t.id === id);
if (!tab) return;
confirmAction('Publish draft to _posts? This moves the file.', async () => {
const relpath = tab.path.replace('source/_drafts/', '');
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'publish', relpath }),
});
const data = await res.json();
if (data.ok) {
showSuccess('Published!');
_closeTab(id);
_postsLoadFn('post');
} else {
showError(data.error || 'Publish failed');
}
});
}
function promptRename(pid, path, onSuccess) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('renameModal'));
const input = document.getElementById('renameInput');
const err = document.getElementById('renameError');
input.value = path.split('/').pop();
err.classList.add('d-none');
const confirm = async () => {
const newName = input.value.trim();
if (!newName) { err.textContent = 'Name required'; err.classList.remove('d-none'); return; }
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'rename', path, newName }),
});
const data = await res.json();
if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; }
modal.hide();
showSuccess('Renamed');
onSuccess(data.newPath);
};
document.getElementById('renameConfirmBtn').onclick = confirm;
input.onkeydown = e => { if (e.key === 'Enter') confirm(); };
modal.show();
setTimeout(() => { input.select(); }, 300);
}
function promptMove(pid, path, onSuccess) {
const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('moveModal'));
const input = document.getElementById('moveInput');
const err = document.getElementById('moveError');
const parts = path.split('/');
parts.pop();
input.value = parts.join('/') + '/';
err.classList.add('d-none');
const confirm = async () => {
const dir = input.value.trim().replace(/\/$/, '');
const file = path.split('/').pop();
const newPath = dir + '/' + file;
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'move', path, newPath }),
});
const data = await res.json();
if (!data.ok) { err.textContent = data.error || 'Error'; err.classList.remove('d-none'); return; }
modal.hide();
showSuccess('Moved');
onSuccess(data.newPath);
};
document.getElementById('moveConfirmBtn').onclick = confirm;
input.onkeydown = e => { if (e.key === 'Enter') confirm(); };
modal.show();
setTimeout(() => { const l = input.value.length; input.setSelectionRange(l, l); input.focus(); }, 300);
}
// Modal editor (used from posts pane where there's no tab bar)
let _modalCM = null;
function _renderModalEditor(pid, path, name, content) {
document.getElementById('fileEditName').textContent = name;
document.getElementById('fileEditPath').value = path;
document.getElementById('fileEditPid').value = pid;
document.getElementById('fileEditStatus').textContent = '';
const container = document.getElementById('fileEditCm');
container.innerHTML = '';
if (_modalCM) { try { _modalCM.toTextArea(); } catch(e) {} _modalCM = null; }
_modalCM = CodeMirror(container, {
value: content, mode: modeForFile(name), theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
extraKeys: { 'Ctrl-S': saveFile, 'Cmd-S': saveFile },
});
const modal = document.getElementById('fileEditModal');
bootstrap.Modal.getOrCreateInstance(modal).show();
modal.addEventListener('shown.bs.modal', () => _modalCM?.refresh(), { once: true });
}
async function saveFile() {
const pid = document.getElementById('fileEditPid').value;
const path = document.getElementById('fileEditPath').value;
const content = _modalCM ? _modalCM.getValue() : '';
const status = document.getElementById('fileEditStatus');
status.textContent = 'Saving…';
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'write', path, content }),
});
const data = await res.json();
if (data.ok) { status.textContent = 'Saved.'; showSuccess('Saved'); }
else { status.textContent = ''; showError(data.error || 'Save failed'); }
}
document.getElementById('fileEditSave')?.addEventListener('click', saveFile);
// ── File browser ──────────────────────────────────────────────────────────────
const fileBrowser = document.getElementById('fileBrowser');
if (fileBrowser) {
const pid = fileBrowser.dataset.projectId;
let fileBrowserCurrentPath = '';
async function loadDir(relPath) {
fileBrowserCurrentPath = relPath;
fileBrowser.dataset.currentPath = relPath;
const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`);
const data = await res.json();
if (data.error) { renderBrowserError(data.error); return; }
renderCrumb(relPath, '#fileCrumb', loadDir);
renderEntries(data.entries);
}
function renderEntries(entries) {
const list = document.getElementById('fileList');
if (!entries.length) { list.innerHTML = 'Empty directory
'; return; }
list.innerHTML = entries.map(e => {
const icon = e.type === 'dir' ? 'bi-folder-fill text-warning' : 'bi-file-text text-secondary';
const kebab = e.type === 'file' ? `
` : '';
return `
${esc(e.name)}
${e.size != null ? `${fmtSize(e.size)} ` : ''}
${kebab}
`;
}).join('');
list.querySelectorAll('[data-type]').forEach(row => {
row.addEventListener('click', ev => {
if (ev.target.closest('button')) return;
row.dataset.type === 'dir' ? loadDir(row.dataset.path) : openFileEditor(pid, row.dataset.path, row.dataset.name);
});
});
list.querySelectorAll('.btn-open-file').forEach(btn => {
btn.addEventListener('click', () => openFileEditor(pid, btn.dataset.path, btn.dataset.name));
});
list.querySelectorAll('.btn-del-file').forEach(btn => {
btn.addEventListener('click', () => {
confirmAction(`Delete "${btn.dataset.name}"?`, async () => {
const r = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', path: btn.dataset.path }),
});
const d = await r.json();
if (d.ok) {
showSuccess('Deleted ' + btn.dataset.name);
// Close the editor tab if this file was open
const openTab = _tabs.find(t => t.path === btn.dataset.path);
if (openTab) _closeTab(openTab.id);
loadDir(fileBrowserCurrentPath);
} else showError(d.error || 'Delete failed');
});
});
});
}
function renderBrowserError(msg) {
document.getElementById('fileList').innerHTML = `${esc(msg)}
`;
}
// Drag-and-drop upload — whole left pane as drop zone
const dropZone = fileBrowser.querySelector('.split-list') || fileBrowser;
let dragDepth = 0;
dropZone.addEventListener('dragenter', e => {
e.preventDefault(); dragDepth++;
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
if (--dragDepth <= 0) { dragDepth = 0; dropZone.classList.remove('drag-over'); }
});
dropZone.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'copy';
});
dropZone.addEventListener('drop', async e => {
e.preventDefault(); dragDepth = 0; dropZone.classList.remove('drag-over');
const files = [...(e.dataTransfer.files || [])];
if (!files.length) return;
let ok = 0;
for (const file of files) {
const fd = new FormData();
fd.append('project_id', pid);
fd.append('folder', fileBrowserCurrentPath);
fd.append('accept', 'any');
fd.append('file', file);
try {
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
data.ok ? ok++ : showError(`${file.name}: ${data.error || 'Upload failed'}`);
} catch(err) { showError(`${file.name}: ${err.message}`); }
}
if (ok > 0) { showSuccess(`Uploaded ${ok} file${ok > 1 ? 's' : ''}`); loadDir(fileBrowserCurrentPath); }
});
const initialPath = new URLSearchParams(location.search).get('path') || '';
loadDir(initialPath);
}
// ── Command runner ────────────────────────────────────────────────────────────
const commandRunner = document.getElementById('commandRunner');
if (commandRunner) {
const pid = commandRunner.dataset.projectId;
const output = document.getElementById('cmdOutput');
document.getElementById('clearOutput')?.addEventListener('click', () => { output.textContent = ''; });
commandRunner.querySelectorAll('.btn-run-cmd').forEach(btn => {
btn.addEventListener('click', async () => {
const cmd = btn.dataset.cmd;
output.textContent += `\n$ ${cmd}\n`;
btn.disabled = true;
const res = await fetch('/api/run', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ project_id: parseInt(pid), cmd }),
});
const data = await res.json();
output.textContent += data.error ? `ERROR: ${data.error}\n` : (data.output || '(no output)') + `\nExit: ${data.exit_code}\n`;
output.scrollTop = output.scrollHeight;
btn.disabled = false;
});
});
}
// ── Post tree helpers ─────────────────────────────────────────────────────────
let _postFolderCounter = 0;
function buildPostTree(items) {
const root = { posts: [], children: {} };
for (const item of items) {
const segs = item.folder ? item.folder.split('/').filter(Boolean) : [];
let node = root;
for (const seg of segs) {
if (!node.children[seg]) node.children[seg] = { posts: [], children: {} };
node = node.children[seg];
}
node.posts.push(item);
}
return root;
}
function countTreePosts(node) {
let c = node.posts.length;
for (const child of Object.values(node.children)) c += countTreePosts(child);
return c;
}
function renderPostItem(p, pid, type) {
const rel = esc(p.relpath);
const path = esc(p.path);
const publishItem = type === 'draft'
? `
Publish
`
: '';
return `
${esc(p.title)}
${p.date ? esc(p.date.substring(0,10)) : '—'} • ${esc(p.filename)}
`;
}
function renderPostTree(node, pid, type, expandPath = []) {
let html = '';
if (node.posts.length) {
html += '';
for (const p of node.posts) html += renderPostItem(p, pid, type);
html += '
';
}
for (const [name, child] of Object.entries(node.children)) {
const id = 'pfg' + (++_postFolderCounter);
const count = countTreePosts(child);
const isOpen = expandPath.length > 0 && expandPath[0] === name;
const sub = isOpen ? expandPath.slice(1) : [];
html += `
${renderPostTree(child, pid, type, sub)}
`;
}
return html;
}
function _wirePostList(list, pid, currentTypeRef) {
list.querySelectorAll('.post-item-row').forEach(row => {
row.addEventListener('click', ev => {
if (ev.target.closest('button')) return;
openFileEditor(pid, row.dataset.path, row.dataset.name);
});
});
list.querySelectorAll('.btn-del-post').forEach(btn => {
btn.addEventListener('click', () => confirmAction(`Delete "${btn.dataset.relpath}"?`, async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'DELETE', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ relpath: btn.dataset.relpath, type: btn.dataset.type }),
});
const d = await res.json();
d.ok ? _postsLoadFn(currentTypeRef.type) : showError(d.error || 'Error');
}));
});
list.querySelectorAll('.btn-dup-post').forEach(btn => {
btn.addEventListener('click', async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'duplicate', relpath: btn.dataset.relpath, type: btn.dataset.type }),
});
const d = await res.json();
d.ok ? (showSuccess('Duplicated as ' + d.relpath), _postsLoadFn(currentTypeRef.type)) : showError(d.error || 'Error');
});
});
list.querySelectorAll('.btn-pub-post').forEach(btn => {
btn.addEventListener('click', () => confirmAction('Publish draft to _posts?', async () => {
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'publish', relpath: btn.dataset.relpath }),
});
const d = await res.json();
if (d.ok) {
showSuccess('Published!');
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) _closeTab(tab.id);
_postsLoadFn('post');
} else { showError(d.error || 'Error'); }
}));
});
list.querySelectorAll('.btn-rename-post').forEach(btn => {
btn.addEventListener('click', () => {
promptRename(pid, btn.dataset.path, newPath => {
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) { tab.path = newPath; tab.name = newPath.split('/').pop(); _renderTabBar(); }
_postsLoadFn(currentTypeRef.type);
});
});
});
list.querySelectorAll('.btn-move-post').forEach(btn => {
btn.addEventListener('click', () => {
promptMove(pid, btn.dataset.path, newPath => {
const tab = _tabs.find(t => t.path === btn.dataset.path);
if (tab) { tab.path = newPath; _renderTabBar(); }
_postsLoadFn(currentTypeRef.type);
});
});
});
}
// Shared reference so modal "Create" button can reload the list
let _postsLoadFn = () => {};
// ── Posts panel (Hexo) ───────────────────────────────────────────────────────
const postsPanel = document.getElementById('postsPanel');
if (postsPanel) {
const pid = postsPanel.dataset.projectId;
const postsList = document.getElementById('postsList');
const searchResults= document.getElementById('searchResults');
let currentType = 'post';
_postsLoadFn = type => loadPostsByType(type);
function setActiveBtn(btnId) {
['showPosts','showPages'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('btn-outline-primary', id === btnId);
el.classList.toggle('active', id === btnId);
el.classList.toggle('btn-outline-secondary', id !== btnId);
});
}
// ── Filter pill (tag/category from Dashboard) ───────────────────────────────
let _activeFilter = null; // {kind, value}
function _renderFilterBar() {
let bar = document.getElementById('postsFilterBar');
if (!bar) {
bar = document.createElement('div');
bar.id = 'postsFilterBar';
bar.className = 'mb-2';
postsList.parentElement.insertBefore(bar, postsList);
}
if (!_activeFilter) { bar.innerHTML = ''; return; }
bar.innerHTML = `
${esc(_activeFilter.kind)}: ${esc(_activeFilter.value)}
`;
document.getElementById('clearFilterBtn').addEventListener('click', () => {
_activeFilter = null;
const url = new URL(location);
url.searchParams.delete('filter');
history.replaceState({}, '', url);
loadPostsByType(currentType);
});
}
function _applyFilter(items) {
if (!_activeFilter) return items;
const { kind, value } = _activeFilter;
return items.filter(it => {
const list = kind === 'tag' ? (it.tags || []) : (it.categories || []);
return list.some(v => String(v).toLowerCase() === value.toLowerCase());
});
}
async function loadPostsByType(type) {
// Drafts are merged into the posts view — always normalise to 'post'
if (type === 'draft') type = 'post';
currentType = type;
window._postsCurrentType = type;
setActiveBtn(type === 'page' ? 'showPages' : 'showPosts');
const sq = document.getElementById('searchQuery');
if (sq) sq.value = '';
searchResults.classList.add('d-none');
postsList.classList.remove('d-none');
postsList.innerHTML = 'Loading…
';
if (type === 'post') {
// Fetch posts + drafts together
const [pr, dr] = await Promise.all([
fetch(`/api/posts?project_id=${pid}&type=post`),
fetch(`/api/posts?project_id=${pid}&type=draft`),
]);
const [postsData, draftsData] = await Promise.all([pr.json(), dr.json()]);
const posts = _applyFilter(postsData.items || []);
const drafts = _applyFilter(draftsData.items || []);
if (!posts.length && !drafts.length) {
postsList.innerHTML = `${_activeFilter ? 'No posts match this filter.' : 'No posts yet.'}
`;
_renderFilterBar();
return;
}
let html = '';
if (drafts.length) {
_postFolderCounter = 0;
const draftTree = renderPostTree(buildPostTree(drafts), pid, 'draft');
html += ``;
}
if (posts.length) {
const expandPath = posts[0]?.folder ? posts[0].folder.split('/').filter(Boolean) : [];
_postFolderCounter = 0;
html += `${renderPostTree(buildPostTree(posts), pid, 'post', expandPath)}
`;
}
postsList.innerHTML = html;
_wirePostList(postsList, pid, { type: 'post' });
_renderFilterBar();
} else {
// Pages
const res = await fetch(`/api/posts?project_id=${pid}&type=${type}`);
const data = await res.json();
if (data.missing_dir) {
postsList.innerHTML = `source/ not found.
`;
_renderFilterBar();
return;
}
const items = _applyFilter(data.items || []);
if (!items.length) {
postsList.innerHTML = `${_activeFilter ? 'No pages match this filter.' : 'No pages yet.'}
`;
_renderFilterBar();
return;
}
const expandPath = items[0]?.folder ? items[0].folder.split('/').filter(Boolean) : [];
_postFolderCounter = 0;
postsList.innerHTML = `${renderPostTree(buildPostTree(items), pid, type, expandPath)}
`;
_wirePostList(postsList, pid, { type });
_renderFilterBar();
}
}
// Read ?filter=tag:foo or ?filter=category:bar from URL
const urlFilter = new URLSearchParams(location.search).get('filter');
if (urlFilter && urlFilter.includes(':')) {
const [k, ...rest] = urlFilter.split(':');
if (k === 'tag' || k === 'category') {
_activeFilter = { kind: k, value: rest.join(':') };
}
}
document.getElementById('showPosts')?.addEventListener('click', () => loadPostsByType('post'));
document.getElementById('showPages')?.addEventListener('click', () => loadPostsByType('page'));
let _newTypeTarget = 'post';
function openNewPostModal(type) {
_newTypeTarget = type;
const labels = { post: 'New Post', draft: 'New Draft', page: 'New Page' };
document.getElementById('newPostModalTitle').textContent = labels[type] ?? 'New';
document.getElementById('newPostTitle').value = '';
document.getElementById('newPostFolder').value = '';
document.getElementById('newPostError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).show();
setTimeout(() => document.getElementById('newPostTitle').focus(), 300);
}
document.getElementById('newItemBtn')?.addEventListener('click', () => {
openNewPostModal(currentType === 'page' ? 'page' : 'post');
});
postsPanel.querySelectorAll('.new-type-btn').forEach(btn => {
btn.addEventListener('click', () => openNewPostModal(btn.dataset.type));
});
document.getElementById('newPostCreateBtn')?.addEventListener('click', async () => {
const title = document.getElementById('newPostTitle').value.trim();
const folder = document.getElementById('newPostFolder').value.trim();
const errEl = document.getElementById('newPostError');
if (!title) { errEl.textContent = 'Title required'; errEl.classList.remove('d-none'); return; }
const date = new Date().toISOString().replace('T', ' ').substring(0, 19);
const fm = `---\ntitle: "${title.replace(/"/g, '\\"')}"\ndate: ${date}\ntags: []\n---`;
const res = await fetch('/api/posts?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ type: _newTypeTarget, title, folder, frontmatter: fm }),
});
const data = await res.json();
if (!data.ok) { errEl.textContent = data.error || 'Error'; errEl.classList.remove('d-none'); return; }
bootstrap.Modal.getOrCreateInstance(document.getElementById('newPostModal')).hide();
await loadPostsByType('post');
openFileEditor(pid, data.path, data.filename);
});
document.getElementById('newPostTitle')?.addEventListener('keydown', e => {
if (e.key === 'Enter') document.getElementById('newPostCreateBtn')?.click();
});
let _searchTimer = null;
document.getElementById('searchQuery')?.addEventListener('input', e => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
if (!q) {
searchResults.classList.add('d-none');
postsList.classList.remove('d-none');
return;
}
postsList.classList.add('d-none');
searchResults.classList.remove('d-none');
searchResults.innerHTML = 'Searching…
';
_searchTimer = setTimeout(async () => {
const res = await fetch(`/api/search?project_id=${pid}&q=${encodeURIComponent(q)}`);
const data = await res.json();
if (data.error) { searchResults.innerHTML = `${esc(data.error)}
`; return; }
if (!data.results.length) { searchResults.innerHTML = 'No results.
'; return; }
searchResults.innerHTML = data.results.map(r => `
${esc(r.file)}
L${r.line}
${esc(r.content)}
`).join('');
searchResults.querySelectorAll('.search-result').forEach(row => {
row.addEventListener('click', () => openFileEditor(pid, row.dataset.path, row.dataset.name));
});
}, 350);
});
document.getElementById('searchClearBtn')?.addEventListener('click', () => {
const sq = document.getElementById('searchQuery');
if (sq) sq.value = '';
searchResults.classList.add('d-none');
postsList.classList.remove('d-none');
});
loadPostsByType('post');
}
// ── Upload modal ──────────────────────────────────────────────────────────────
// Pre-fill folder from file browser when modal opens
document.getElementById('uploadModal')?.addEventListener('show.bs.modal', () => {
const browser = document.getElementById('fileBrowser');
const folder = document.getElementById('uploadFolder');
if (browser && folder) folder.value = browser.dataset.currentPath || '';
document.getElementById('uploadResult')?.classList.add('d-none');
const uf = document.getElementById('uploadFile');
if (uf) uf.value = '';
});
const uploadSubmit = document.getElementById('uploadSubmit');
if (uploadSubmit) {
uploadSubmit.addEventListener('click', async () => {
const pid = document.getElementById('uploadPid').value;
const accept = document.getElementById('uploadAccept').value;
const folder = document.getElementById('uploadFolder').value.trim();
const file = document.getElementById('uploadFile').files[0];
if (!file) { showError('No file selected'); return; }
const fd = new FormData();
fd.append('project_id', pid);
fd.append('folder', folder);
fd.append('accept', accept);
fd.append('file', file);
uploadSubmit.disabled = true;
const res = await fetch('/api/upload', { method: 'POST', body: fd });
const data = await res.json();
uploadSubmit.disabled = false;
if (!data.ok) { showError(data.error || 'Upload failed'); return; }
const resultDiv = document.getElementById('uploadResult');
const urlInput = document.getElementById('uploadResultUrl');
resultDiv.classList.remove('d-none');
document.getElementById('uploadResultMsg').textContent = `Uploaded: ${data.filename}`;
urlInput.value = data.url || data.path;
document.getElementById('uploadCopy')?.addEventListener('click', () => {
navigator.clipboard.writeText(urlInput.value).then(() => showSuccess('Copied!'));
}, { once: true });
});
}
// ── Media grid (Storage) ──────────────────────────────────────────────────────
const mediaPanel = document.getElementById('mediaPanel');
if (mediaPanel) {
const pid = mediaPanel.dataset.projectId;
const projectUrl = mediaPanel.dataset.projectUrl;
async function loadMedia(relPath) {
const res = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(relPath)}`);
const data = await res.json();
if (data.error) { document.getElementById('mediaGrid').innerHTML = `${esc(data.error)}
`; return; }
renderCrumb(relPath, '#mediaCrumb', loadMedia);
renderMediaGrid(data.entries, relPath);
}
function renderMediaGrid(entries, relPath) {
const grid = document.getElementById('mediaGrid');
if (!entries.length) { grid.innerHTML = 'Empty directory
'; return; }
grid.innerHTML = entries.map(e => {
if (e.type === 'dir') {
return ``;
}
const thumb = isImage(e.name)
? ` `
: ` `;
const fileUrl = projectUrl ? rtrim(projectUrl, '/') + '/' + e.path : e.path;
return ``;
}).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 = 'Root ';
let acc = '';
for (const p of parts) {
acc = acc ? acc + '/' + p : p;
html += `${esc(p)} `;
}
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 => `${esc(s)} `).join('');
sel.classList.remove('d-none');
document.getElementById('gitSubdirUseBtn').onclick = () => {
gitSubdir = opts.value;
sel.classList.add('d-none');
loadGitStatus();
loadGitLog();
};
} else {
document.getElementById('gitNoRepo').classList.remove('d-none');
}
return;
}
if (data.error) { showError(data.error); return; }
document.getElementById('gitStatus').classList.remove('d-none');
document.getElementById('gitBranch').innerHTML = ` ${esc(data.branch)}`;
const files = data.files || [];
const stash = data.stash_count || 0;
let summary = files.length
? `${files.length} changed file${files.length > 1 ? 's' : ''}`
: 'Working tree clean';
if (stash) summary += ` · ${stash} stash${stash > 1 ? 'es' : ''}`;
document.getElementById('gitStatusSummary').textContent = summary;
const div = document.getElementById('gitFiles');
if (files.length) {
div.innerHTML = `` +
files.map(f => `
${esc(f.xy)}
${esc(f.file)}
Diff
`).join('') + `
Add selected
`;
div.querySelectorAll('.btn-git-diff').forEach(btn => {
btn.addEventListener('click', () => showGitDiff('', btn.dataset.file));
});
document.getElementById('gitAddSelectedBtn')?.addEventListener('click', async () => {
const checked = [...div.querySelectorAll('.git-file-check:checked')].map(cb => cb.dataset.file);
if (!checked.length) { showError('Select files to add first'); return; }
const d = await (await gitPost({ action: 'stage', files: checked })).json();
d.ok ? (showSuccess(`Staged ${checked.length} file${checked.length > 1 ? 's' : ''}`), loadGitStatus())
: showError(d.output || 'Stage failed');
});
} else {
div.innerHTML = '';
}
}
async function loadGitLog() {
const res = await fetch(gitParams('action=log'));
const data = await res.json();
const tbl = document.getElementById('gitLogTable');
if (!data.commits?.length) { tbl.innerHTML = 'No commits yet.
'; return; }
tbl.innerHTML = `` + data.commits.map(c => `
${esc(c.subject)}
${esc(c.rel)}
${esc(c.short)} • ${esc(c.author)}
`).join('') + `
`;
tbl.querySelectorAll('.btn-git-show-commit').forEach(row => {
row.addEventListener('click', () => showGitDiff(row.dataset.hash, ''));
});
}
async function loadGitBranches() {
const res = await fetch(gitParams('action=branches'));
const data = await res.json();
if (!data.branches) return;
const list = document.getElementById('gitBranchList');
list.innerHTML = data.branches.map(b => `
${esc(b.name)}
${b.current ? 'current ' : ''}
${!b.current ? `Switch ` : ''}
`).join('');
list.querySelectorAll('.btn-checkout').forEach(btn => {
btn.addEventListener('click', async () => {
const d = await (await gitPost({ action: 'checkout', branch: btn.dataset.branch })).json();
if (d.ok) { showSuccess('Switched to ' + btn.dataset.branch); loadGitStatus(); loadGitBranches(); }
else showError(d.output || 'Checkout failed');
});
});
}
async function showGitDiff(hash, file) {
const params = new URLSearchParams({ action: 'diff' });
if (gitSubdir) params.set('subdir', gitSubdir);
if (hash) params.append('hash', hash);
if (file) params.append('file', file);
const res = await fetch(`/api/git?project_id=${pid}&` + params);
const data = await res.json();
document.getElementById('gitDiffContent').innerHTML = colorDiff(data.diff || '(empty)');
document.getElementById('gitDiffTitle').textContent = hash
? `Commit ${hash.substring(0, 7)}` : `Diff: ${file}`;
bootstrap.Modal.getOrCreateInstance(document.getElementById('gitDiffModal')).show();
}
function colorDiff(raw) {
return raw.split('\n').map(line => {
if (line.startsWith('+') && !line.startsWith('+++')) return `${esc(line)} `;
if (line.startsWith('-') && !line.startsWith('---')) return `${esc(line)} `;
if (line.startsWith('@@')) return `${esc(line)} `;
if (/^(diff |index |---|[+]{3})/.test(line)) return `${esc(line)} `;
return esc(line);
}).join('\n');
}
function gitPost(body) {
if (gitSubdir) body.subdir = gitSubdir;
return fetch('/api/git?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
}
async function runGitStream(action) {
const out = document.getElementById('gitStreamOutput');
out.classList.remove('d-none');
out.textContent = `git ${action}…\n`;
const res = await gitPost({ action });
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
try {
const d = JSON.parse(line.slice(6));
if (d.line) out.textContent += d.line;
if (d.done) {
out.textContent += `\nExit: ${d.exit_code}`;
d.exit_code === 0
? showSuccess(`git ${action} done`)
: showError(`git ${action} failed (exit ${d.exit_code})`);
loadGitStatus();
if (action === 'pull') loadGitLog();
}
if (d.error) showError(d.error);
} catch(e) {}
}
out.scrollTop = out.scrollHeight;
}
}
document.getElementById('gitPullBtn')?.addEventListener('click', () => runGitStream('pull'));
document.getElementById('gitPushBtn')?.addEventListener('click', () => runGitStream('push'));
document.getElementById('gitCommitBtn')?.addEventListener('click', async () => {
const msg = document.getElementById('gitCommitMsg').value.trim();
if (!msg) { showError('Commit message required'); return; }
const res = await gitPost({ action: 'commit', message: msg });
const data = await res.json();
if (data.ok) {
showSuccess('Committed');
document.getElementById('gitCommitMsg').value = '';
loadGitStatus(); loadGitLog();
} else {
showError(data.output || 'Commit failed');
}
});
document.getElementById('gitStashBtn')?.addEventListener('click', async () => {
const d = await (await gitPost({ action: 'stash' })).json();
d.ok ? (showSuccess('Stashed'), loadGitStatus()) : showError(d.output || 'Error');
});
document.getElementById('gitStashPopBtn')?.addEventListener('click', async () => {
const d = await (await gitPost({ action: 'stash_pop' })).json();
d.ok ? (showSuccess('Applied stash'), loadGitStatus()) : showError(d.output || 'Error');
});
document.getElementById('gitResetBtn')?.addEventListener('click', () => {
confirmAction('Reset to HEAD? All uncommitted changes will be lost.', async () => {
const d = await (await gitPost({ action: 'reset' })).json();
d.ok ? (showSuccess('Reset to HEAD'), loadGitStatus()) : showError(d.output || 'Error');
});
});
document.getElementById('gitCreateBranchBtn')?.addEventListener('click', async () => {
const branch = document.getElementById('gitNewBranch').value.trim();
if (!branch) return;
const d = await (await gitPost({ action: 'create_branch', branch })).json();
if (d.ok) {
showSuccess('Created ' + branch);
document.getElementById('gitNewBranch').value = '';
loadGitBranches(); loadGitStatus();
} else {
showError(d.output || 'Error');
}
});
// Sub-tab switching
document.getElementById('gitTabLogBtn')?.addEventListener('click', () => {
document.getElementById('gitTabLogBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary');
document.getElementById('gitTabLogBtn').classList.add('active');
document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary');
document.getElementById('gitTabBranchesBtn').classList.remove('active');
document.getElementById('gitLogPanel').classList.remove('d-none');
document.getElementById('gitBranchesPanel').classList.add('d-none');
});
document.getElementById('gitTabBranchesBtn')?.addEventListener('click', () => {
document.getElementById('gitTabBranchesBtn').classList.replace('btn-outline-secondary', 'btn-outline-primary');
document.getElementById('gitTabBranchesBtn').classList.add('active');
document.getElementById('gitTabLogBtn').classList.replace('btn-outline-primary', 'btn-outline-secondary');
document.getElementById('gitTabLogBtn').classList.remove('active');
document.getElementById('gitBranchesPanel').classList.remove('d-none');
document.getElementById('gitLogPanel').classList.add('d-none');
loadGitBranches();
});
loadGitStatus();
loadGitLog();
}
// ── Config editor (multi-file) ────────────────────────────────────────────────
const configPanel = document.getElementById('configPanel');
if (configPanel) {
const pid = configPanel.dataset.projectId;
let configCM = null;
let configCurrentPath = '_config.yml';
// Discover available config files (root _config*.yml + themes/*/ _config.yml)
async function discoverConfigs() {
const sel = document.getElementById('configFileSelect');
if (!sel) return;
try {
const res = await fetch(`/api/files?project_id=${pid}&path=`);
const data = await res.json();
const yamls = (data.entries || [])
.filter(e => e.type === 'file' && /^_config.*\.ya?ml$/i.test(e.name))
.map(e => ({ path: e.path, label: e.name + (e.name === '_config.yml' ? ' (blog)' : '') }));
// Also check themes/ for theme configs
try {
const tr = await fetch(`/api/files?project_id=${pid}&path=themes`);
const td = await tr.json();
for (const d of (td.entries || []).filter(e => e.type === 'dir')) {
const cr = await fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent('themes/' + d.name)}`);
const cd = await cr.json();
if ((cd.entries || []).some(e => e.name === '_config.yml')) {
yamls.push({ path: 'themes/' + d.name + '/_config.yml', label: d.name + ' theme config' });
}
}
} catch(e) {}
if (yamls.length > 1) {
sel.innerHTML = yamls.map(y =>
`${esc(y.label)} `
).join('');
}
} catch(e) {}
}
function loadConfig(path) {
configCurrentPath = path;
fetch(`/api/files?project_id=${pid}&path=${encodeURIComponent(path)}&action=read`)
.then(r => r.json())
.then(data => {
const container = document.getElementById('configEditor');
if (data.error) {
container.innerHTML = `${esc(data.error)}
`;
return;
}
container.innerHTML = '';
if (configCM) { try { configCM.toTextArea(); } catch(e) {} configCM = null; }
configCM = CodeMirror(container, {
value: data.content, mode: 'yaml', theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
extraKeys: { 'Ctrl-S': saveConfig, 'Cmd-S': saveConfig },
});
configCM.setSize('100%', 'var(--hm-tab-height)');
requestAnimationFrame(() => requestAnimationFrame(() => configCM?.refresh()));
});
}
async function saveConfig() {
if (!configCM) return;
const status = document.getElementById('configSaveStatus');
status.textContent = 'Saving…';
const res = await fetch('/api/files?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'write', path: configCurrentPath, content: configCM.getValue() }),
});
const data = await res.json();
if (data.ok) { status.textContent = 'Saved.'; showSuccess('Config saved'); }
else { status.textContent = ''; showError(data.error || 'Save failed'); }
}
document.getElementById('configSaveBtn')?.addEventListener('click', saveConfig);
document.getElementById('configFileSelect')?.addEventListener('change', function() {
loadConfig(this.value);
});
discoverConfigs().then(() => loadConfig('_config.yml'));
}
// ── Full-text search ──────────────────────────────────────────────────────────
// ── Tag/category browser ──────────────────────────────────────────────────────
const tagsPanel = document.getElementById('tagsPanel');
if (tagsPanel) {
const pid = tagsPanel.dataset.projectId;
fetch(`/api/tags?project_id=${pid}`)
.then(r => r.json())
.then(data => {
document.getElementById('tagsLoading').classList.add('d-none');
document.getElementById('tagsContent').classList.remove('d-none');
const tags = data.tags || {};
const cats = data.categories || {};
document.getElementById('tagsCount').textContent = Object.keys(tags).length;
document.getElementById('catsCount').textContent = Object.keys(cats).length;
document.getElementById('tagCloud').innerHTML = renderTagCloud(tags, 'tag');
document.getElementById('catCloud').innerHTML = renderTagCloud(cats, 'category');
})
.catch(() => {
document.getElementById('tagsLoading').textContent = 'Failed to load.';
});
function renderTagCloud(map, kind) {
const entries = Object.entries(map);
if (!entries.length) return 'None found.
';
const max = entries[0][1];
return entries.map(([name, count]) => {
const size = Math.max(75, Math.min(160, Math.round(count / max * 100 + 60)));
const href = `/project/${pid}?tab=posts&filter=${encodeURIComponent(kind + ':' + name)}`;
return `${esc(name)}${count} `;
}).join(' ');
}
}
// ── Command history ───────────────────────────────────────────────────────────
document.getElementById('showHistoryBtn')?.addEventListener('click', async () => {
const runner = document.getElementById('commandRunner');
if (!runner) return;
const pid = runner.dataset.projectId;
const res = await fetch(`/api/run?project_id=${pid}`);
const data = await res.json();
const list = document.getElementById('cmdHistoryList');
if (!list) return;
if (!data.history?.length) {
list.innerHTML = 'No history yet.
';
} else {
list.innerHTML = data.history.map(h => `
${esc(h.cmd)}
Exit ${h.exit_code}
${esc(h.run_at)}
${h.output ? `
${esc(h.output.substring(0,500))} ` : ''}
`).join('');
}
bootstrap.Modal.getOrCreateInstance(document.getElementById('cmdHistoryModal')).show();
});
// ── Post duplicate ────────────────────────────────────────────────────────────
// Handled via event delegation inside loadPosts(); trigger is .btn-dup-post
// ── Settings tab ─────────────────────────────────────────────────────────────
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) {
const pid = settingsPanel.dataset.projectId;
let tplCM = null, snipCM = null;
// ── Project rename ──
document.getElementById('projectNameSaveBtn')?.addEventListener('click', async () => {
const name = document.getElementById('projectNameInput')?.value.trim();
if (!name) { showError('Name required'); return; }
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'update_name', id: parseInt(pid), name }),
});
const data = await res.json();
if (data.ok) showSuccess('Name updated');
else showError(data.error || 'Error');
});
// ── Project type ──
document.getElementById('projectTypeSaveBtn')?.addEventListener('click', async () => {
const type = document.getElementById('projectTypeSelectSettings')?.value;
if (!type) return;
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'update_type', id: parseInt(pid), type }),
});
const data = await res.json();
if (data.ok) location.reload();
else showError(data.error || 'Error');
});
// ── Page directories ──
document.getElementById('pageDirsSaveBtn')?.addEventListener('click', async () => {
const dirs = document.getElementById('pageDirsInput')?.value ?? '';
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'update_setting', id: parseInt(pid), key: 'page_dirs', value: dirs }),
});
const data = await res.json();
if (data.ok) showSuccess('Page dirs saved');
else showError(data.error || 'Error');
});
// ── Templates ──
async function loadTemplates() {
const res = await fetch(`/api/templates?project_id=${pid}`);
const data = await res.json();
const list = document.getElementById('templatesList');
if (!data.templates?.length) {
list.innerHTML = 'No templates yet.
';
return;
}
list.innerHTML = data.templates.map(t => `
${esc(t.name)}
${esc(t.type)}
Edit
`).join('');
list.querySelectorAll('.btn-edit-template').forEach(btn => {
btn.addEventListener('click', () => openTemplateModal(btn.dataset.id, btn.dataset.name, btn.dataset.content));
});
list.querySelectorAll('.btn-del-template').forEach(btn => {
btn.addEventListener('click', () => confirmAction('Delete this template?', async () => {
const r = await fetch('/api/templates', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }),
});
const d = await r.json();
d.ok ? loadTemplates() : showError(d.error || 'Error');
}));
});
}
function openTemplateModal(id, name, content) {
document.getElementById('templateId').value = id || '';
document.getElementById('templateName').value = name || '';
document.getElementById('templateModalTitle').textContent = id ? 'Edit template' : 'New template';
const container = document.getElementById('templateContentEditor');
container.innerHTML = '';
if (tplCM) { try { tplCM.toTextArea(); } catch(e) {} tplCM = null; }
tplCM = CodeMirror(container, {
value: content || '', mode: 'markdown', theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
});
bootstrap.Modal.getOrCreateInstance(document.getElementById('templateModal')).show();
setTimeout(() => tplCM && tplCM.refresh(), 200);
}
document.getElementById('newTemplateBtn')?.addEventListener('click', () => openTemplateModal('', '', ''));
document.getElementById('templateSaveBtn')?.addEventListener('click', async () => {
const id = document.getElementById('templateId').value;
const name = document.getElementById('templateName').value.trim();
const content = tplCM ? tplCM.getValue() : '';
if (!name) { showError('Name required'); return; }
const body = id
? { action: 'update', id: parseInt(id), name, content }
: { action: 'create', project_id: parseInt(pid), name, type: 'post', content };
const res = await fetch('/api/templates', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (data.ok) {
bootstrap.Modal.getInstance(document.getElementById('templateModal')).hide();
showSuccess(id ? 'Template updated' : 'Template created');
loadTemplates();
} else {
showError(data.error || 'Error');
}
});
// ── Snippets ──
async function loadSnippets() {
const res = await fetch(`/api/snippets?project_id=${pid}`);
const data = await res.json();
const list = document.getElementById('snippetsList');
if (!data.snippets?.length) {
list.innerHTML = 'No snippets yet.
';
return;
}
list.innerHTML = data.snippets.map(s => `
`).join('');
list.querySelectorAll('.btn-edit-snippet').forEach(btn => {
btn.addEventListener('click', () => openSnippetModal(btn.dataset.id, btn.dataset.name, btn.dataset.content));
});
list.querySelectorAll('.btn-del-snippet').forEach(btn => {
btn.addEventListener('click', () => confirmAction('Delete this snippet?', async () => {
const r = await fetch('/api/snippets', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', id: parseInt(btn.dataset.id) }),
});
const d = await r.json();
d.ok ? loadSnippets() : showError(d.error || 'Error');
}));
});
}
function openSnippetModal(id, name, content) {
document.getElementById('snippetId').value = id || '';
document.getElementById('snippetName').value = name || '';
document.getElementById('snippetModalTitle').textContent = id ? 'Edit snippet' : 'New snippet';
const container = document.getElementById('snippetContentEditor');
container.innerHTML = '';
if (snipCM) { try { snipCM.toTextArea(); } catch(e) {} snipCM = null; }
snipCM = CodeMirror(container, {
value: content || '', mode: 'markdown', theme: 'dracula',
lineNumbers: true, lineWrapping: true, tabSize: 2,
});
bootstrap.Modal.getOrCreateInstance(document.getElementById('snippetModal')).show();
setTimeout(() => snipCM && snipCM.refresh(), 200);
}
document.getElementById('newSnippetBtn')?.addEventListener('click', () => openSnippetModal('', '', ''));
document.getElementById('snippetSaveBtn')?.addEventListener('click', async () => {
const id = document.getElementById('snippetId').value;
const name = document.getElementById('snippetName').value.trim();
const content = snipCM ? snipCM.getValue() : '';
if (!name) { showError('Name required'); return; }
const body = id
? { action: 'update', id: parseInt(id), name, content }
: { action: 'create', project_id: parseInt(pid), name, content };
const res = await fetch('/api/snippets', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
const data = await res.json();
if (data.ok) {
bootstrap.Modal.getInstance(document.getElementById('snippetModal')).hide();
showSuccess(id ? 'Snippet updated' : 'Snippet created');
loadSnippets();
} else {
showError(data.error || 'Error');
}
});
if (document.getElementById('templatesList')) loadTemplates();
if (document.getElementById('snippetsList')) loadSnippets();
}
// ── Audit log page ────────────────────────────────────────────────────────────
const auditTable = document.getElementById('auditTable');
if (auditTable) {
let auditOffset = 0;
const auditLimit = 50;
async function loadAudit(offset = 0) {
auditOffset = offset;
const pid = document.getElementById('auditProjectFilter')?.value || '';
const params = new URLSearchParams({ limit: auditLimit, offset });
if (pid) params.append('project_id', pid);
const res = await fetch('/api/audit?' + params);
const data = await res.json();
if (!data.entries?.length) {
auditTable.innerHTML = 'No entries.
';
} else {
auditTable.innerHTML = `
Time User Project Action Detail IP
` + data.entries.map(e => `
${esc(e.created_at)}
${esc(e.username ?? e.user_id ?? '—')}
${esc(e.project_name ?? (e.project_id ? '#' + e.project_id : '—'))}
${esc(e.action)}
${esc(e.detail ?? '')}
${esc(e.ip ?? '')}
`).join('') + `
`;
}
const total = data.total ?? 0;
document.getElementById('auditPrev').classList.toggle('d-none', offset === 0);
document.getElementById('auditNext').classList.toggle('d-none', offset + auditLimit >= total);
}
document.getElementById('auditProjectFilter')?.addEventListener('change', () => loadAudit(0));
document.getElementById('auditPrev')?.addEventListener('click', () => loadAudit(Math.max(0, auditOffset - auditLimit)));
document.getElementById('auditNext')?.addEventListener('click', () => loadAudit(auditOffset + auditLimit));
loadAudit();
}
// ── Settings: scan paths ──────────────────────────────────────────────────────
const addScanPathForm = document.getElementById('addScanPathForm');
if (addScanPathForm) {
addScanPathForm.addEventListener('submit', async e => {
e.preventDefault();
const fd = new FormData(addScanPathForm);
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'add_scan_path', path: fd.get('path'), depth: parseInt(fd.get('depth')) }),
});
const data = await res.json();
data.ok ? location.reload() : showError(data.error || 'Error');
});
document.querySelectorAll('.btn-remove-scan').forEach(btn => {
btn.addEventListener('click', () => confirmAction('Remove this scan path?', async () => {
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'remove_scan_path', id: parseInt(btn.dataset.id) }),
});
const data = await res.json();
if (data.ok) location.reload();
}));
});
document.querySelectorAll('.btn-scan').forEach(btn => {
btn.addEventListener('click', async () => {
btn.disabled = true;
const res = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'scan', path: btn.dataset.path, depth: parseInt(btn.dataset.depth) }),
});
const data = await res.json();
btn.disabled = false;
if (data.error) { showError(data.error); return; }
const resultsDiv = document.getElementById('scanResults');
const listDiv = document.getElementById('scanResultsList');
resultsDiv.classList.remove('d-none');
if (!data.found.length) { listDiv.innerHTML = 'No projects found.
'; return; }
listDiv.innerHTML = data.found.map(p => `
${esc(p.name)}
${esc(p.path)}
${esc(p.type_name)}
Add
`).join('');
listDiv.querySelectorAll('.btn-add-found').forEach(b => {
b.addEventListener('click', async () => {
const r = await fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ name: b.dataset.name, path: b.dataset.path, type: b.dataset.type }),
});
const d = await r.json();
if (d.ok) { b.innerHTML = ' '; b.disabled = true; b.classList.replace('btn-primary','btn-success'); }
else showError(d.error || 'Error');
});
});
});
});
document.getElementById('closeScanResults')?.addEventListener('click', () => {
document.getElementById('scanResults').classList.add('d-none');
});
}
// ── Scratchpad: per-project notes ─────────────────────────────────────────────
(() => {
const card = document.getElementById('scratchpadCard');
if (!card) return;
const pid = parseInt(card.dataset.projectId);
const input = document.getElementById('scratchpadInput');
const status = document.getElementById('scratchpadStatus');
let timer = null;
let lastSaved = input.value;
const setStatus = (msg, cls = 'text-muted') => {
status.className = 'small ms-auto ' + cls;
status.textContent = msg;
};
const save = async () => {
if (input.value === lastSaved) return;
setStatus('Saving…');
try {
const r = await fetch('/api/scratchpad?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ content: input.value }),
});
const j = await r.json();
if (j.ok) {
lastSaved = input.value;
setStatus('Saved', 'text-success');
setTimeout(() => setStatus(''), 1500);
} else {
setStatus(j.error || 'Save failed', 'text-danger');
}
} catch (e) {
setStatus('Save failed', 'text-danger');
}
};
input.addEventListener('input', () => {
setStatus('Editing…');
clearTimeout(timer);
timer = setTimeout(save, 800);
});
input.addEventListener('blur', () => { clearTimeout(timer); save(); });
})();
// ── Recent files tab ──────────────────────────────────────────────────────────
(() => {
const panel = document.getElementById('recentPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const list = document.getElementById('recentList');
const fmtAgo = ts => {
const t = Date.parse(ts.replace(' ', 'T') + 'Z');
const s = Math.max(1, Math.floor((Date.now() - t) / 1000));
if (s < 60) return s + 's ago';
if (s < 3600) return Math.floor(s / 60) + 'm ago';
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
if (s < 86400 * 7) return Math.floor(s / 86400) + 'd ago';
return new Date(t).toLocaleDateString();
};
async function load() {
const r = await fetch('/api/recent?project_id=' + pid);
const d = await r.json();
if (!d.items?.length) {
list.innerHTML = 'No recent files yet — open or save a file to start tracking.
';
return;
}
list.innerHTML = d.items.map(it => `
${esc(it.name)}
${esc(it.dir || '/')}
${fmtAgo(it.opened_at)}
`).join('');
list.querySelectorAll('.btn-edit-recent').forEach(b => {
b.addEventListener('click', () => openFileEditor(pid, b.dataset.path, b.dataset.name));
});
list.querySelectorAll('.btn-remove-recent').forEach(b => {
b.addEventListener('click', async () => {
await fetch('/api/recent?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'remove', path: b.dataset.path }),
});
load();
});
});
}
document.getElementById('recentClearBtn').addEventListener('click', () => {
confirmAction('Clear all recent files for this project?', async () => {
await fetch('/api/recent?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'clear' }),
});
load();
});
});
load();
})();
// ── Disk usage: dashboard cards + project page badge ─────────────────────────
(() => {
// Project header pill
const badge = document.getElementById('diskBadge');
if (badge) {
const pid = badge.dataset.projectId;
fetch('/api/disk?project_id=' + pid).then(r => r.json()).then(d => {
if (d.size?.human) {
document.getElementById('diskBadgeValue').textContent = d.size.human;
badge.classList.remove('d-none');
}
});
}
// Dashboard card pills (bulk)
const cards = [...document.querySelectorAll('.project-disk')];
if (cards.length) {
const ids = cards.map(c => c.dataset.projectId).join(',');
fetch('/api/disk?ids=' + ids).then(r => r.json()).then(d => {
cards.forEach(c => {
const item = d.items?.[c.dataset.projectId];
if (item?.human) {
c.querySelector('.project-disk-value').textContent = item.human;
c.classList.remove('d-none');
}
});
});
}
})();
// ── Activity feed (dashboard) ────────────────────────────────────────────────
(() => {
const feed = document.getElementById('activityFeed');
if (!feed) return;
const ACTION_LABELS = {
git_commit: 'commit', git_push: 'push', git_pull: 'pull', git_merge: 'merge',
git_reset: 'reset', git_stage: 'stage', git_unstage: 'unstage',
git_discard: 'discard', git_fetch: 'fetch',
backup_download: 'backup', link_scan: 'link scan',
theme_switch: 'theme switch', theme_clone: 'theme clone', theme_delete: 'theme delete',
theme_git_pull: 'theme pull', theme_git_push: 'theme push', theme_git_fetch: 'theme fetch',
plugin_install: 'plugin install', plugin_uninstall: 'plugin uninstall',
scheduled_build: 'scheduled build',
bot_start: 'bot started', bot_stop: 'bot stopped', bot_restart: 'bot restarted',
botconfig_streamer_add: 'streamer added', botconfig_streamer_save: 'streamer updated',
botconfig_streamer_remove: 'streamer removed',
botconfig_rss_add: 'feed added', botconfig_rss_save: 'feed updated',
botconfig_rss_remove: 'feed removed',
botconfig_mastodon_add: 'mastodon added', botconfig_mastodon_save: 'mastodon updated',
botconfig_mastodon_remove: 'mastodon removed',
botconfig_linkedin_page_add: 'linkedin page added', botconfig_linkedin_page_save: 'linkedin page updated',
botconfig_linkedin_page_remove: 'linkedin page removed',
botconfig_discord_channel_add: 'discord channel added', botconfig_discord_channel_save: 'discord channel updated',
botconfig_discord_channel_remove: 'discord channel removed',
linkedin_oauth_connect: 'linkedin connected',
botcompose_rss_repost: 'rss repost queued',
botcompose_mastodon_post: 'mastodon posted',
botcompose_linkedin_post: 'linkedin posted',
botcompose_discord_post: 'discord posted',
scheduled_post_create: 'post scheduled',
scheduled_post_delete: 'scheduled post deleted',
file_write: 'file edit', file_delete: 'file delete', file_upload: 'upload',
post_create: 'post created', post_delete: 'post deleted',
post_publish: 'post published', post_duplicate: 'post duplicated',
draft_create: 'draft created', draft_update: 'draft updated',
draft_delete: 'draft deleted', draft_publish: 'draft published',
command_run: 'command run',
project_add: 'project added', project_delete: 'project removed',
project_rename: 'project renamed', project_type_change: 'type changed',
project_pin: 'pinned', project_unpin: 'unpinned',
project_setting: 'setting changed',
scan_path_add: 'scan path added', scan_path_delete: 'scan path removed',
schedule_create: 'schedule added', schedule_update: 'schedule updated',
schedule_delete: 'schedule removed',
template_create: 'template added', template_update: 'template updated',
template_delete: 'template deleted',
snippet_create: 'snippet added', snippet_update: 'snippet updated',
snippet_delete: 'snippet deleted',
};
const ACTION_ICONS = {
git_commit: 'bi-git', git_push: 'bi-arrow-up-circle', git_pull: 'bi-arrow-down-circle',
git_fetch: 'bi-arrow-repeat', git_merge: 'bi-sign-merge-right',
git_reset: 'bi-arrow-counterclockwise',
git_stage: 'bi-plus-circle', git_unstage: 'bi-dash-circle', git_discard: 'bi-x-circle',
backup_download: 'bi-download', link_scan: 'bi-link-45deg',
theme_switch: 'bi-palette', theme_clone: 'bi-cloud-download', theme_delete: 'bi-trash',
theme_git_pull: 'bi-arrow-down-circle', theme_git_push: 'bi-arrow-up-circle',
theme_git_fetch: 'bi-arrow-repeat',
plugin_install: 'bi-puzzle', plugin_uninstall: 'bi-puzzle',
scheduled_build: 'bi-clock',
bot_start: 'bi-play-circle', bot_stop: 'bi-stop-circle', bot_restart: 'bi-arrow-clockwise',
botconfig_streamer_add: 'bi-twitch', botconfig_streamer_save: 'bi-twitch',
botconfig_streamer_remove: 'bi-twitch',
botconfig_rss_add: 'bi-rss', botconfig_rss_save: 'bi-rss', botconfig_rss_remove: 'bi-rss',
botconfig_mastodon_add: 'bi-mastodon', botconfig_mastodon_save: 'bi-mastodon', botconfig_mastodon_remove: 'bi-mastodon',
botconfig_linkedin_page_add: 'bi-linkedin', botconfig_linkedin_page_save: 'bi-linkedin', botconfig_linkedin_page_remove: 'bi-linkedin',
botconfig_discord_channel_add: 'bi-discord', botconfig_discord_channel_save: 'bi-discord', botconfig_discord_channel_remove: 'bi-discord',
linkedin_oauth_connect: 'bi-linkedin',
botcompose_rss_repost: 'bi-arrow-repeat',
botcompose_mastodon_post: 'bi-mastodon',
botcompose_linkedin_post: 'bi-linkedin',
botcompose_discord_post: 'bi-discord',
scheduled_post_create: 'bi-calendar-plus',
scheduled_post_delete: 'bi-calendar-x',
file_write: 'bi-pencil', file_delete: 'bi-trash', file_upload: 'bi-cloud-upload',
post_create: 'bi-file-earmark-plus', post_delete: 'bi-file-earmark-x',
post_publish: 'bi-send', post_duplicate: 'bi-files',
draft_create: 'bi-pencil-square', draft_update: 'bi-pencil-square',
draft_delete: 'bi-trash', draft_publish: 'bi-send',
command_run: 'bi-terminal',
project_add: 'bi-plus-square', project_delete: 'bi-trash',
project_rename: 'bi-pencil', project_type_change: 'bi-arrow-left-right',
project_pin: 'bi-pin-fill', project_unpin: 'bi-pin',
project_setting: 'bi-sliders',
scan_path_add: 'bi-folder-plus', scan_path_delete: 'bi-folder-minus',
schedule_create: 'bi-clock', schedule_update: 'bi-clock', schedule_delete: 'bi-clock',
template_create: 'bi-file-earmark-text', template_update: 'bi-file-earmark-text',
template_delete: 'bi-file-earmark-text',
snippet_create: 'bi-code-square', snippet_update: 'bi-code-square',
snippet_delete: 'bi-code-square',
};
fetch('/api/audit?limit=20').then(r => r.json()).then(d => {
if (!d.entries?.length) {
feed.innerHTML = 'No activity yet.
';
return;
}
feed.innerHTML = d.entries.map(e => {
const label = ACTION_LABELS[e.action] || e.action;
const icon = ACTION_ICONS[e.action] || 'bi-circle';
const proj = e.project_name
? `${esc(e.project_name)} `
: '— ';
const detail = e.detail
? `${esc(e.detail)} ` : '';
return `
${esc(label)}
${proj}
${detail}
${esc(e.created_at)}
`;
}).join('');
});
})();
// ── Broken link checker (links tab) ──────────────────────────────────────────
(() => {
const panel = document.getElementById('linksPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const wrap = document.getElementById('linksTableWrap');
const info = document.getElementById('linksRunInfo');
const onlyB = document.getElementById('linksOnlyBroken');
const btn = document.getElementById('linksScanBtn');
function statusBadge(code, error) {
if (error) return `err ${esc(error)} `;
if (code == null) return `? `;
if (code >= 400) return `${code} `;
if (code >= 300) return `${code} `;
return `${code} `;
}
async function load() {
const url = '/api/links?project_id=' + pid + (onlyB.checked ? '&broken_only=1' : '');
const d = await (await fetch(url)).json();
if (!d.run) {
info.textContent = 'No scan run yet.';
wrap.innerHTML = '';
return;
}
const r = d.run;
info.innerHTML = `Last scan ${esc(r.finished_at || r.started_at)} —
${r.total_links} links, ${r.broken} broken `;
if (!d.results.length) {
wrap.innerHTML = `${onlyB.checked ? 'No broken links 🎉' : 'No links recorded.'}
`;
return;
}
wrap.innerHTML = `
Status URL Source
${d.results.map(x => `
${statusBadge(x.status_code, x.error)}
${esc(x.url)}
${esc(x.source)}
`).join('')}
`;
}
btn.addEventListener('click', async () => {
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = ' Scanning…';
info.textContent = 'Scanning… this can take a while for large projects.';
wrap.innerHTML = '';
try {
const r = await fetch('/api/links?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'scan' }),
});
const d = await r.json();
if (d.error) showError(d.error);
else showSuccess(`Scan done — ${d.broken} broken of ${d.total}`);
await load();
} catch (e) {
showError(e.message);
} finally {
btn.disabled = false;
btn.innerHTML = orig;
}
});
onlyB.addEventListener('change', load);
load();
})();
// ── Themes tab ───────────────────────────────────────────────────────────────
(() => {
const panel = document.getElementById('themesPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const list = document.getElementById('themesList');
function gitChip(g) {
if (!g.has_git) return 'no git ';
const parts = [` ${esc(g.branch || '?')}`];
if (g.dirty) parts.push('● dirty ');
if (g.ahead) parts.push(`↑${g.ahead} `);
if (g.behind) parts.push(`↓${g.behind} `);
return `${parts.join(' ')} `;
}
function renderTheme(t) {
const g = t.git;
return `
${esc(t.name)}
${t.active ? 'active ' : ''}
${gitChip(g)}
${g.remote ? `
${esc(g.remote)}
` : ''}
${g.commit ? `
${esc(g.commit)}
` : ''}
`;
}
async function load() {
list.innerHTML = 'Loading…
';
const d = await (await fetch('/api/themes?project_id=' + pid)).json();
if (d.no_themes_dir) {
list.innerHTML = 'No themes/ directory found in this project.
';
return;
}
if (!d.themes.length) {
list.innerHTML = 'No themes installed yet. Clone one from a git URL.
';
return;
}
list.innerHTML = d.themes.map(renderTheme).join('');
list.querySelectorAll('.btn-theme-switch').forEach(b => {
b.addEventListener('click', async () => {
const r = await fetch('/api/themes?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'switch', name: b.dataset.name }),
});
const j = await r.json();
if (j.ok) { showSuccess('Theme switched to ' + b.dataset.name); load(); }
else showError(j.error || 'Switch failed');
});
});
list.querySelectorAll('.btn-theme-git').forEach(b => {
b.addEventListener('click', async () => {
b.disabled = true;
const r = await fetch('/api/themes?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'git', name: b.dataset.name, op: b.dataset.op }),
});
const j = await r.json();
b.disabled = false;
if (j.ok) { showSuccess(b.dataset.op + ' ok'); load(); }
else showError(j.error || j.log || 'Git op failed');
});
});
list.querySelectorAll('.btn-theme-delete').forEach(b => {
b.addEventListener('click', () => {
confirmAction('Delete theme "' + b.dataset.name + '"? Files will be removed from disk.', async () => {
const r = await fetch('/api/themes?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'delete', name: b.dataset.name }),
});
const j = await r.json();
if (j.ok) { showSuccess('Deleted'); load(); }
else showError(j.error || 'Delete failed');
});
});
});
}
document.getElementById('cloneThemeSubmit').addEventListener('click', async () => {
const url = document.getElementById('cloneThemeUrl').value.trim();
const name = document.getElementById('cloneThemeName').value.trim();
const log = document.getElementById('cloneThemeLog');
if (!url) { log.textContent = 'URL required'; return; }
log.textContent = 'Cloning…';
const r = await fetch('/api/themes?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'clone', url, name }),
});
const j = await r.json();
log.textContent = j.log || (j.error || 'Done');
if (j.ok) {
showSuccess('Cloned theme ' + j.name);
bootstrap.Modal.getInstance(document.getElementById('cloneThemeModal')).hide();
document.getElementById('cloneThemeUrl').value = '';
document.getElementById('cloneThemeName').value = '';
load();
} else {
showError(j.error || 'Clone failed');
}
});
load();
})();
// ── Plugins tab ──────────────────────────────────────────────────────────────
(() => {
const panel = document.getElementById('pluginsPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const list = document.getElementById('pluginsList');
const log = document.getElementById('pluginInstallLog');
function renderPlugin(p) {
const installed = p.installed
? `v${esc(p.installed)} `
: `not installed (run npm install) `;
return `
${esc(p.name)}
${installed}
range ${esc(p.version)}
${p.description ? `
${esc(p.description)}
` : ''}
npm
${p.repository ? ` ·
repo ` : ''}
${p.homepage && p.homepage !== p.repository ? ` ·
docs ` : ''}
`;
}
async function load() {
list.innerHTML = 'Loading…
';
const d = await (await fetch('/api/plugins?project_id=' + pid)).json();
if (d.no_package_json) {
list.innerHTML = 'No package.json found in this project.
';
return;
}
if (!d.plugins.length) {
list.innerHTML = 'No hexo-* plugins installed.
';
return;
}
list.innerHTML = d.plugins.map(renderPlugin).join('');
list.querySelectorAll('.btn-plugin-uninstall').forEach(b => {
b.addEventListener('click', () => {
confirmAction('Uninstall ' + b.dataset.name + '?', async () => {
b.disabled = true;
log.textContent = 'Uninstalling…'; log.classList.remove('d-none');
const r = await fetch('/api/plugins?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'uninstall', name: b.dataset.name }),
});
const j = await r.json();
log.textContent = j.log || j.error || 'Done';
if (j.ok) { showSuccess('Uninstalled'); load(); }
else showError(j.error || 'Uninstall failed');
});
});
});
}
document.getElementById('pluginInstallBtn').addEventListener('click', async () => {
const name = document.getElementById('pluginInstallName').value.trim();
if (!name) return;
log.textContent = 'Installing ' + name + '…'; log.classList.remove('d-none');
const r = await fetch('/api/plugins?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'install', name }),
});
const j = await r.json();
log.textContent = j.log || j.error || 'Done';
if (j.ok) {
showSuccess('Installed ' + name);
document.getElementById('pluginInstallName').value = '';
load();
} else {
showError(j.error || 'Install failed');
}
});
load();
})();
// ── Scheduled builds (settings tab) ──────────────────────────────────────────
(() => {
const panel = document.getElementById('settingsPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const list = document.getElementById('schedulesList');
const newBtn = document.getElementById('newScheduleBtn');
if (!list || !newBtn) return;
async function api(body) {
const r = await fetch('/api/schedules?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
return r.json();
}
function bindRow(row) {
const id = parseInt(row.dataset.id);
row.querySelector('.schedule-save').addEventListener('click', async () => {
const j = await api({
action: 'update', id,
cmd_id: row.querySelector('.schedule-cmd').value,
cron: row.querySelector('.schedule-cron').value.trim(),
is_enabled: row.querySelector('.schedule-enabled').checked,
});
j.ok ? showSuccess('Saved') : showError(j.error || 'Save failed');
});
row.querySelector('.schedule-enabled').addEventListener('change', async (e) => {
await api({ action: 'update', id, is_enabled: e.target.checked });
});
row.querySelector('.schedule-delete').addEventListener('click', () => {
confirmAction('Delete this schedule?', async () => {
const j = await api({ action: 'delete', id });
if (j.ok) row.remove();
});
});
}
list.querySelectorAll('.schedule-row').forEach(bindRow);
newBtn.addEventListener('click', async () => {
const firstCmd = panel.querySelector('.schedule-cmd')?.options[0]?.value
|| document.querySelector('.btn-run-cmd')?.dataset.cmd
|| 'generate';
const j = await api({ action: 'create', cmd_id: firstCmd, cron: '0 3 * * *', is_enabled: 1 });
if (j.id) location.reload();
else showError(j.error || 'Create failed');
});
})();
// ── Server-log analytics — Settings tab controls ────────────────────────────
(() => {
const root = document.getElementById('analyticsSettings');
if (!root) return;
const pid = parseInt(root.dataset.projectId);
const status = document.getElementById('analyticsStatus');
async function saveSetting(key, value) {
return fetch('/api/projects', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'update_setting', id: pid, key, value }),
}).then(r => r.json());
}
document.getElementById('analyticsSaveBtn').addEventListener('click', async () => {
await saveSetting('analytics_log_path', document.getElementById('analyticsLogPath').value.trim());
await saveSetting('analytics_log_format', document.getElementById('analyticsLogFormat').value);
await saveSetting('analytics_log_filter', document.getElementById('analyticsLogFilter').value.trim());
showSuccess('Saved');
});
document.getElementById('analyticsImportBtn').addEventListener('click', async () => {
const btn = document.getElementById('analyticsImportBtn');
btn.disabled = true;
const orig = btn.innerHTML;
btn.innerHTML = ' Importing…';
status.textContent = 'Importing…';
const r = await fetch('/api/analytics_import?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'run' }),
});
const j = await r.json();
btn.disabled = false; btn.innerHTML = orig;
if (j.ok) {
showSuccess(`Imported ${j.imported} new rows (skipped ${j.skipped})`);
status.textContent = `Last import just now — added ${j.imported} rows, skipped ${j.skipped}.`;
} else {
showError(j.error || 'Import failed');
status.textContent = 'Last import failed: ' + (j.error || 'unknown error');
}
});
document.getElementById('analyticsResetBtn').addEventListener('click', () => {
confirmAction('Reset import position? Next "Import now" will re-scan the log from the start.', async () => {
const r = await fetch('/api/analytics_import?project_id=' + pid, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ action: 'reset' }),
});
const j = await r.json();
if (j.ok) showSuccess('Position reset');
else showError(j.error || 'Reset failed');
});
});
})();
// ── Analytics (project Analytics tab) ────────────────────────────────────────
(() => {
const panel = document.getElementById('analyticsPanel');
if (!panel) return;
const pid = parseInt(panel.dataset.projectId);
const body = document.getElementById('analyticsBody');
const range = document.getElementById('analyticsRange');
const refresh = document.getElementById('analyticsRefreshBtn');
function renderEmpty() {
body.innerHTML = `
No visits recorded yet. Configure the access-log path below and click
Import now to load history.
`;
}
function delta(cur, prev) {
if (prev === 0) return cur === 0 ? { txt: '', cls: 'text-muted' } : { txt: 'new', cls: 'text-success' };
const pct = Math.round((cur - prev) / prev * 100);
if (pct === 0) return { txt: '0%', cls: 'text-muted' };
if (pct > 0) return { txt: '+' + pct + '%', cls: 'text-success' };
return { txt: pct + '%', cls: 'text-danger' };
}
function dayKey(date) { return date.toISOString().slice(0, 10); }
// SVG line chart with current + previous-period overlay.
function renderLineChart(series, prevSeries, days) {
const w = 720, h = 160, padL = 30, padR = 8, padT = 8, padB = 22;
const innerW = w - padL - padR;
const innerH = h - padT - padB;
const today = new Date(); today.setHours(0,0,0,0);
const cur = {}; series.forEach(s => cur[s.day] = s.views);
const prv = {}; prevSeries.forEach(s => prv[s.day] = s.views);
// Build full-day arrays so missing days appear as 0.
const curArr = [], prvArr = [];
for (let i = days - 1; i >= 0; i--) {
const d = new Date(today); d.setDate(today.getDate() - i);
const dPrev = new Date(today); dPrev.setDate(today.getDate() - i - days);
curArr.push(cur[dayKey(d)] || 0);
prvArr.push(prv[dayKey(dPrev)] || 0);
}
const max = Math.max(1, ...curArr, ...prvArr);
const stepX = days > 1 ? innerW / (days - 1) : innerW;
const toXY = (i, v) => [padL + i * stepX, padT + innerH - (v / max) * innerH];
const path = (arr) => arr.map((v, i) => {
const [x, y] = toXY(i, v);
return (i === 0 ? 'M' : 'L') + x.toFixed(1) + ',' + y.toFixed(1);
}).join(' ');
const fill = curArr.map((v, i) => {
const [x, y] = toXY(i, v);
return (i === 0 ? `M${x.toFixed(1)},${(padT+innerH).toFixed(1)} L${x.toFixed(1)},${y.toFixed(1)}`
: ` L${x.toFixed(1)},${y.toFixed(1)}`);
}).join('') + ` L${(padL + innerW).toFixed(1)},${(padT+innerH).toFixed(1)} Z`;
// Y-axis ticks (0 / max/2 / max)
const ticks = [0, Math.round(max / 2), max].map(v => {
const y = padT + innerH - (v / max) * innerH;
return `
${v} `;
}).join('');
// X-axis labels — first/middle/last
const labels = [0, Math.floor((days - 1) / 2), days - 1].map(i => {
const d = new Date(today); d.setDate(today.getDate() - (days - 1 - i));
const x = padL + i * stepX;
return `${
d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
} `;
}).join('');
return `
${ticks}
${labels}
`;
}
// 24-hour bar chart
function renderHours(hours) {
const byHour = {}; hours.forEach(h => byHour[h.hour] = h.views);
const max = Math.max(1, ...Object.values(byHour));
const w = 360, h = 64, gap = 2, barW = (w - 23 * gap) / 24;
const bars = [];
for (let i = 0; i < 24; i++) {
const v = byHour[i] || 0;
const bh = (v / max) * (h - 14);
const x = i * (barW + gap);
const y = h - 12 - bh;
bars.push(` `);
}
const labels = [0, 6, 12, 18].map(i => {
const x = i * (barW + gap) + barW / 2;
return `${i}h `;
}).join('');
return `
${bars.join('')}${labels}
`;
}
function renderTable(rows, html) {
if (!rows.length) return '—
';
return html;
}
async function load() {
body.innerHTML = 'Loading…
';
const days = parseInt(range.value);
const r = await fetch(`/api/analytics?project_id=${pid}&days=${days}`);
const d = await r.json();
if (d.error) { body.innerHTML = `${esc(d.error)}
`; return; }
if (d.all_time.views === 0) { renderEmpty(); return; }
const dV = delta(d.window.views, d.previous.views);
const dU = delta(d.window.uniques, d.previous.uniques);
const top = d.top_pages.map(p => `
${esc(p.path)}
${p.views}
${p.uniques}
`).join('');
const refs = d.top_refs.length
? d.top_refs.map(r => `
${esc(r.referrer)}
${r.views}
`).join('')
: '';
const rows404 = (d.top_404s || []).map(r => `
${esc(r.path)}
${r.hits}
${esc((r.last_hit || '').replace('T', ' ').slice(0, 16))}
`).join('');
body.innerHTML = `
Views (last ${d.days}d)
${d.window.views}
${dV.txt}
vs prev ${d.days}d (${d.previous.views})
Unique visitors
${d.window.uniques}
${dU.txt}
vs prev (${d.previous.uniques})
All-time views
${d.all_time.views}
${d.all_time.uniques} uniques
Tracking since
${esc((d.all_time.first_seen || '—').slice(0, 10))}
last hit ${esc((d.all_time.last_seen || '—').slice(0, 16).replace('T', ' '))}
● last ${d.days}d
— — previous ${d.days}d
${renderLineChart(d.series, d.prev_series || [], d.days)}
Referrers
${renderTable(d.top_refs, `
`)}
Hour of day
${renderHours(d.hours || [])}
${rows404 ? `
Top 404s in this window
Path Hits Last seen
${rows404}
` : ''}`;
}
range.addEventListener('change', load);
refresh?.addEventListener('click', load);
load();
})();
// ── Project sidebar collapse toggle ──────────────────────────────────────────
(() => {
const sb = document.getElementById('projectSidebar');
if (!sb) return;
const KEY = 'hackmancms_sidebar_collapsed';
if (localStorage.getItem(KEY) === '1') sb.classList.add('collapsed');
document.getElementById('sidebarToggle')?.addEventListener('click', () => {
sb.classList.toggle('collapsed');
localStorage.setItem(KEY, sb.classList.contains('collapsed') ? '1' : '0');
});
})();
// ── Keyboard shortcuts ───────────────────────────────────────────────────────
(() => {
let chord = null;
let chordTimer = null;
const isTyping = () => {
const el = document.activeElement;
if (!el) return false;
if (el.isContentEditable) return true;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
function handle(key) {
if (chord === 'g') {
chord = null;
if (key === 'd') location.href = '/';
else if (key === 's') location.href = '/settings';
else if (key === 'a') location.href = '/audit';
return;
}
if (key === '?') {
const m = document.getElementById('shortcutsModal');
if (m) bootstrap.Modal.getOrCreateInstance(m).show();
return;
}
if (key === 'g') {
chord = 'g';
clearTimeout(chordTimer);
chordTimer = setTimeout(() => { chord = null; }, 1200);
return;
}
// Project page only
if (key === 'n') {
const btn = document.getElementById('newItemBtn');
if (btn) { btn.click(); return; }
}
if (key === 'b') {
const btn = document.querySelector('.btn-run-cmd[data-cmd="generate"]');
if (btn) { btn.click(); showSuccess('Triggered generate'); return; }
}
}
document.addEventListener('keydown', e => {
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (isTyping()) return;
if (e.key === '?') { e.preventDefault(); handle('?'); return; }
if (/^[a-z]$/.test(e.key)) handle(e.key);
});
})();
// ── Restore persisted editor tabs on page load ───────────────────────────────
window.addEventListener('DOMContentLoaded', () => {
setTimeout(() => { try { _restoreTabState(); } catch(e) {} }, 50);
});
// ── Bot: control panel ────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botControlPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
const dot = document.getElementById('botStatusDot');
const txt = document.getElementById('botStatusText');
const since = document.getElementById('botSince');
const svc = document.getElementById('botService');
const outputWrap = document.getElementById('botCmdOutputWrap');
const output = document.getElementById('botCmdOutput');
function setStatus(data) {
const active = data.active;
dot.style.background = active ? '#198754' : '#dc3545';
txt.textContent = active ? 'Running' : (data.status || 'Stopped');
since.textContent = data.since ? 'since ' + data.since : '';
svc.textContent = data.service ? data.service + '.service' : '';
}
async function loadStatus() {
try {
const r = await fetch('/api/botcontrol?action=status&project_id=' + pid);
const d = await r.json();
if (d.error) { txt.textContent = d.error; return; }
setStatus(d);
} catch (e) { txt.textContent = 'Error loading status'; }
}
async function runAction(action) {
outputWrap.classList.remove('d-none');
output.textContent = action + '…';
try {
const r = await fetch('/api/botcontrol', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action }),
});
const d = await r.json();
output.textContent = d.output || (d.ok ? 'Done.' : 'Failed.');
setStatus(d);
} catch (e) { output.textContent = 'Request failed: ' + e.message; }
}
document.getElementById('botStartBtn').addEventListener('click', () => runAction('start'));
document.getElementById('botStopBtn').addEventListener('click', () => runAction('stop'));
document.getElementById('botRestartBtn').addEventListener('click', () => runAction('restart'));
document.getElementById('botRefreshStatusBtn').addEventListener('click', loadStatus);
document.getElementById('botClearOutputBtn').addEventListener('click', () => {
output.textContent = '';
outputWrap.classList.add('d-none');
});
loadStatus();
})();
// ── Bot: config editor ────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botConfigPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
function rgbToHex([r, g, b]) {
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
}
function hexToRgb(hex) {
const n = parseInt(hex.slice(1), 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
let _config = null;
async function loadConfig() {
const r = await fetch('/api/botconfig?action=get&project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
_config = d.config;
renderStreamers(_config.twitch || {});
renderFeeds(_config.rss || {});
renderMastodon(_config.mastodon || {});
renderDiscordChannels(_config.discord_channels || {});
renderLinkedinPages(_config.linkedin?.pages || {});
renderLinkedinConnection(_config.linkedin || {});
}
// ── Streamers ────────────────────────────────────────────────────────────────
function renderStreamers(twitch) {
const list = document.getElementById('streamerList');
const names = Object.keys(twitch);
if (!names.length) { list.innerHTML = 'No streamers configured.
'; return; }
list.innerHTML = names.map(name => {
const s = twitch[name];
const hex = rgbToHex(s.color || [255, 255, 255]);
return `
${esc(name)}
ch: ${esc(String(s.channel_id ?? '–'))}
${s.role_id ? `
role: ${esc(String(s.role_id))} ` : ''}
`;
}).join('');
list.querySelectorAll('.edit-streamer-btn').forEach(b => b.addEventListener('click', () => openStreamerModal('edit', b.dataset.name)));
list.querySelectorAll('.del-streamer-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_streamer', b.dataset.name, `Remove streamer "${b.dataset.name}"?`, 'Streamer removed')));
}
function openStreamerModal(mode, name = '') {
const s = (mode === 'edit' && _config?.twitch?.[name]) || {};
document.getElementById('streamerModalTitle').textContent = mode === 'add' ? 'Add Streamer' : 'Edit Streamer';
document.getElementById('streamerModalMode').value = mode;
document.getElementById('streamerName').value = name;
document.getElementById('streamerName').disabled = mode === 'edit';
document.getElementById('streamerChannelId').value = s.channel_id ?? '';
document.getElementById('streamerRoleId').value = s.role_id ?? '';
document.getElementById('streamerColor').value = rgbToHex(s.color || [100, 65, 165]);
document.getElementById('streamerModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).show();
}
document.getElementById('addStreamerBtn').addEventListener('click', () => openStreamerModal('add'));
document.getElementById('streamerModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('streamerModalMode').value;
const name = document.getElementById('streamerName').value.trim();
const errEl = document.getElementById('streamerModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_streamer' : 'save_streamer', name, data: {
channel_id: document.getElementById('streamerChannelId').value.trim(),
role_id: document.getElementById('streamerRoleId').value.trim(),
color: hexToRgb(document.getElementById('streamerColor').value),
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('streamerModal')).hide(); showSuccess('Streamer saved'); loadConfig(); }
});
// ── RSS feeds ────────────────────────────────────────────────────────────────
function renderFeeds(rss) {
const list = document.getElementById('rssList');
const names = Object.keys(rss);
if (!names.length) { list.innerHTML = 'No RSS feeds configured.
'; return; }
list.innerHTML = names.map(name => {
const f = rss[name];
const hex = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]);
const activeBadge = f.active
? 'active '
: 'paused ';
const acct = f.mastodon_account ? ` ${esc(f.mastodon_account)} ` : '';
const liPage = f.linkedin_page ? ` ${esc(f.linkedin_page)} ` : '';
return `
${esc(name)} ${activeBadge}
${acct}${liPage}
${esc(f.rss_url || '–')}
`;
}).join('');
list.querySelectorAll('.repost-rss-btn').forEach(b => b.addEventListener('click', () => {
const name = b.dataset.name;
confirmAction(`Repeat last post for feed "${name}"?\nThe bot will repost it on the next poll (within 5 min).`, async () => {
const r = await fetch('/api/botcompose', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'rss_repost', feed_name: name }),
});
const d = await r.json();
if (d.ok) showSuccess(d.message || 'Repost queued — bot will post on next poll');
else showError(d.error || 'Failed');
});
}));
list.querySelectorAll('.edit-rss-btn').forEach(b => b.addEventListener('click', () => openRssModal('edit', b.dataset.name)));
list.querySelectorAll('.del-rss-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_rss', b.dataset.name, `Remove RSS feed "${b.dataset.name}"?`, 'Feed removed')));
}
function populateSelect(id, options, selected) {
const sel = document.getElementById(id);
sel.innerHTML = '— none — ' +
options.map(([v, l]) => `${esc(l)} `).join('');
}
function openRssModal(mode, name = '') {
const f = (mode === 'edit' && _config?.rss?.[name]) || {};
document.getElementById('rssModalTitle').textContent = mode === 'add' ? 'Add RSS Feed' : 'Edit RSS Feed';
document.getElementById('rssModalMode').value = mode;
document.getElementById('rssName').value = name;
document.getElementById('rssName').disabled = mode === 'edit';
document.getElementById('rssUrl').value = f.rss_url ?? '';
document.getElementById('rssChannelId').value = f.channel_id ?? '';
document.getElementById('rssRoleId').value = f.role_id ?? '';
document.getElementById('rssColor').value = rgbToHex(Array.isArray(f.color) ? f.color : [255, 215, 0]);
document.getElementById('rssActive').checked = f.active !== false;
populateSelect('rssMastodonAccount',
Object.entries(_config?.mastodon || {}).map(([k, v]) => [k, v.label || k]),
f.mastodon_account || '');
populateSelect('rssLinkedinPage',
Object.entries(_config?.linkedin?.pages || {})
.filter(([, v]) => (v.type ?? 'organization') === 'personal')
.map(([k, v]) => [k, v.label || k]),
f.linkedin_page || '');
document.getElementById('rssModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).show();
}
document.getElementById('addRssBtn').addEventListener('click', () => openRssModal('add'));
document.getElementById('rssModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('rssModalMode').value;
const name = document.getElementById('rssName').value.trim();
const errEl = document.getElementById('rssModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Name required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_rss' : 'save_rss', name, data: {
rss_url: document.getElementById('rssUrl').value.trim(),
channel_id: document.getElementById('rssChannelId').value.trim(),
role_id: document.getElementById('rssRoleId').value.trim(),
color: hexToRgb(document.getElementById('rssColor').value),
active: document.getElementById('rssActive').checked,
mastodon_account: document.getElementById('rssMastodonAccount').value,
linkedin_page: document.getElementById('rssLinkedinPage').value,
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('rssModal')).hide(); showSuccess('Feed saved'); loadConfig(); }
});
// ── Mastodon accounts ─────────────────────────────────────────────────────────
function renderMastodon(mastodon) {
const list = document.getElementById('mastodonList');
const names = Object.keys(mastodon);
if (!names.length) { list.innerHTML = 'No accounts configured.
'; return; }
list.innerHTML = names.map(name => {
const a = mastodon[name];
return `
${esc(a.label || name)}
MASTODON_TOKEN_${esc(name.toUpperCase())}
${esc(a.api_base_url || '')}
`;
}).join('');
list.querySelectorAll('.compose-mastodon-btn').forEach(b => b.addEventListener('click', () => openComposeModal('mastodon', b.dataset.name, b.dataset.label)));
list.querySelectorAll('.edit-mastodon-btn').forEach(b => b.addEventListener('click', () => openMastodonModal('edit', b.dataset.name)));
list.querySelectorAll('.del-mastodon-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_mastodon', b.dataset.name, `Remove account "${b.dataset.name}"?`, 'Account removed')));
}
function openMastodonModal(mode, name = '') {
const a = (mode === 'edit' && _config?.mastodon?.[name]) || {};
document.getElementById('mastodonModalTitle').textContent = mode === 'add' ? 'Add Mastodon Account' : 'Edit Mastodon Account';
document.getElementById('mastodonModalMode').value = mode;
document.getElementById('mastodonName').value = name;
document.getElementById('mastodonName').disabled = mode === 'edit';
document.getElementById('mastodonLabel').value = a.label ?? '';
document.getElementById('mastodonBase').value = a.api_base_url ?? '';
document.getElementById('mastodonModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).show();
}
document.getElementById('addMastodonBtn').addEventListener('click', () => openMastodonModal('add'));
document.getElementById('mastodonModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('mastodonModalMode').value;
const name = document.getElementById('mastodonName').value.trim();
const errEl = document.getElementById('mastodonModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_mastodon' : 'save_mastodon', name, data: {
label: document.getElementById('mastodonLabel').value.trim(),
api_base_url: document.getElementById('mastodonBase').value.trim(),
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('mastodonModal')).hide(); showSuccess('Account saved'); loadConfig(); }
});
// ── Discord channels ──────────────────────────────────────────────────────────
function renderDiscordChannels(channels) {
const list = document.getElementById('discordChannelList');
if (!list) return;
const names = Object.keys(channels);
if (!names.length) { list.innerHTML = 'No channels configured.
'; return; }
list.innerHTML = names.map(name => {
const ch = channels[name];
return `
${esc(ch.label || name)}
DISCORD_WEBHOOK_${esc(name.toUpperCase())}
`;
}).join('');
list.querySelectorAll('.edit-discord-btn').forEach(b => b.addEventListener('click', () => openDiscordChannelModal('edit', b.dataset.name)));
list.querySelectorAll('.del-discord-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_discord_channel', b.dataset.name, `Remove channel "${b.dataset.name}"?`, 'Channel removed')));
}
function openDiscordChannelModal(mode, name = '') {
const ch = (mode === 'edit' && _config?.discord_channels?.[name]) || {};
document.getElementById('discordChannelModalTitle').textContent = mode === 'add' ? 'Add Discord Channel' : 'Edit Discord Channel';
document.getElementById('discordChannelModalMode').value = mode;
document.getElementById('discordChannelName').value = name;
document.getElementById('discordChannelName').disabled = mode === 'edit';
document.getElementById('discordChannelLabel').value = ch.label ?? '';
document.getElementById('discordChannelModalError').classList.add('d-none');
bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).show();
}
document.getElementById('addDiscordChannelBtn').addEventListener('click', () => openDiscordChannelModal('add'));
document.getElementById('discordChannelModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('discordChannelModalMode').value;
const name = document.getElementById('discordChannelName').value.trim();
const errEl = document.getElementById('discordChannelModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({
action: mode === 'add' ? 'add_discord_channel' : 'save_discord_channel',
name,
data: { label: document.getElementById('discordChannelLabel').value.trim() },
}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('discordChannelModal')).hide(); showSuccess('Channel saved'); loadConfig(); }
});
// ── LinkedIn pages ────────────────────────────────────────────────────────────
function renderLinkedinConnection(li) {
const label = document.getElementById('liConnectionLabel');
const expiry = document.getElementById('liTokenExpiry');
const connected = li.access_token === '***';
label.textContent = connected ? 'Connected to LinkedIn' : 'Not connected';
label.className = 'small fw-semibold ' + (connected ? 'text-success' : 'text-muted');
expiry.textContent = li.token_expiry ? 'Expires: ' + li.token_expiry : '';
document.getElementById('liConnectBtn').classList.toggle('d-none', connected);
document.getElementById('liTestPostBtn').classList.toggle('d-none', !connected);
document.getElementById('liReconnectBtn').classList.toggle('d-none', !connected);
}
async function _liOAuthRedirect() {
const r = await fetch('/api/linkedin_auth?project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
window.location.href = d.url;
}
document.getElementById('liConnectBtn').addEventListener('click', _liOAuthRedirect);
document.getElementById('liReconnectBtn').addEventListener('click', _liOAuthRedirect);
document.getElementById('liTestPostBtn').addEventListener('click', () => {
const pages = _config?.linkedin?.pages || {};
const personalKey = Object.keys(pages).find(k => pages[k].type === 'personal');
if (!personalKey) { showError('No personal LinkedIn profile configured — add one under LinkedIn Pages.'); return; }
openComposeModal('linkedin', personalKey, pages[personalKey].label || personalKey);
});
function renderLinkedinPages(pages) {
const list = document.getElementById('linkedinPagesList');
const names = Object.keys(pages);
if (!names.length) { list.innerHTML = 'No profiles configured.
'; return; }
list.innerHTML = names.map(name => {
const p = pages[name];
const isPersonal = p.type === 'personal';
const badge = isPersonal
? `Personal `
: `Org (posting not supported) `;
const composeBtn = isPersonal
? `
`
: '';
return `
${esc(p.label || name)}
${badge}
${composeBtn}
`;
}).join('');
list.querySelectorAll('.compose-linkedin-btn').forEach(b => b.addEventListener('click', () => openComposeModal('linkedin', b.dataset.name, b.dataset.label)));
list.querySelectorAll('.edit-lipage-btn').forEach(b => b.addEventListener('click', () => openLinkedinPageModal('edit', b.dataset.name)));
list.querySelectorAll('.del-lipage-btn').forEach(b => b.addEventListener('click', () => deleteItem('remove_linkedin_page', b.dataset.name, `Remove page "${b.dataset.name}"?`, 'Page removed')));
}
function _liPageTypeToggle() {
const isPersonal = document.getElementById('linkedinPageType').value === 'personal';
document.getElementById('linkedinPageOrgIdRow').classList.toggle('d-none', isPersonal);
}
function openLinkedinPageModal(mode, name = '') {
const p = (mode === 'edit' && _config?.linkedin?.pages?.[name]) || {};
document.getElementById('linkedinPageModalTitle').textContent = mode === 'add' ? 'Add LinkedIn Page' : 'Edit LinkedIn Page';
document.getElementById('linkedinPageModalMode').value = mode;
document.getElementById('linkedinPageName').value = name;
document.getElementById('linkedinPageName').disabled = mode === 'edit';
document.getElementById('linkedinPageLabel').value = p.label ?? '';
document.getElementById('linkedinPageType').value = p.type ?? 'organization';
document.getElementById('linkedinPageOrgId').value = p.organization_id ?? '';
document.getElementById('linkedinPageModalError').classList.add('d-none');
_liPageTypeToggle();
bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).show();
}
document.getElementById('linkedinPageType').addEventListener('change', _liPageTypeToggle);
document.getElementById('addLinkedinPageBtn').addEventListener('click', () => openLinkedinPageModal('add'));
document.getElementById('linkedinPageModalSaveBtn').addEventListener('click', async () => {
const mode = document.getElementById('linkedinPageModalMode').value;
const name = document.getElementById('linkedinPageName').value.trim();
const type = document.getElementById('linkedinPageType').value;
const errEl = document.getElementById('linkedinPageModalError');
errEl.classList.add('d-none');
if (!name) { errEl.textContent = 'Key required'; errEl.classList.remove('d-none'); return; }
const ok = await apiPost({ action: mode === 'add' ? 'add_linkedin_page' : 'save_linkedin_page', name, data: {
label: document.getElementById('linkedinPageLabel').value.trim(),
type,
organization_id: type === 'organization' ? document.getElementById('linkedinPageOrgId').value.trim() : '',
}}, errEl);
if (ok) { bootstrap.Modal.getOrCreateInstance(document.getElementById('linkedinPageModal')).hide(); showSuccess('Page saved'); loadConfig(); }
});
// ── Compose (Mastodon / LinkedIn direct post) ─────────────────────────────────
function openComposeModal(type, name, label) {
document.getElementById('composeModalTitle').textContent = 'Post to ' + label;
document.getElementById('composeModalType').value = type;
document.getElementById('composeModalName').value = name;
document.getElementById('composeText').value = '';
document.getElementById('composeModalError').classList.add('d-none');
document.getElementById('composeHint').textContent = type === 'mastodon'
? 'Plain text post — no image upload from this composer.'
: 'Text post to your LinkedIn profile.';
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).show();
setTimeout(() => document.getElementById('composeText').focus(), 300);
}
document.getElementById('composeModalPostBtn').addEventListener('click', async () => {
const type = document.getElementById('composeModalType').value;
const name = document.getElementById('composeModalName').value;
const text = document.getElementById('composeText').value.trim();
const errEl = document.getElementById('composeModalError');
errEl.classList.add('d-none');
if (!text) { errEl.textContent = 'Text is required'; errEl.classList.remove('d-none'); return; }
const body = { project_id: +pid, action: type === 'mastodon' ? 'mastodon_post' : 'linkedin_post', text };
if (type === 'mastodon') body.account_name = name;
else body.page_name = name;
const btn = document.getElementById('composeModalPostBtn');
btn.disabled = true;
const r = await fetch('/api/botcompose', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
btn.disabled = false;
if (d.ok) {
bootstrap.Modal.getOrCreateInstance(document.getElementById('composeModal')).hide();
showSuccess('Posted!');
} else {
errEl.textContent = d.error || 'Post failed';
errEl.classList.remove('d-none');
}
});
// ── Shared helpers ────────────────────────────────────────────────────────────
async function apiPost(body, errEl) {
const r = await fetch('/api/botconfig', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, ...body }),
});
const d = await r.json();
if (d.error && errEl) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return false; }
if (d.error) { showError(d.error); return false; }
return true;
}
function deleteItem(action, name, confirmMsg, successMsg) {
confirmAction(confirmMsg, async () => {
const ok = await apiPost({ action, name }, null);
if (ok) { showSuccess(successMsg); loadConfig(); }
});
}
loadConfig();
})();
// ── Bot: schedule editor ─────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botSchedulePanel');
if (!panel) return;
const pid = panel.dataset.projectId;
let _targets = { discord: [], mastodon: [], linkedin: [] };
async function loadTargets() {
const r = await fetch('/api/post_schedule?action=targets&project_id=' + pid);
const d = await r.json();
if (d.error) { showError(d.error); return; }
_targets = d;
renderPicker('Discord', 'schedDiscordChannels', 'schedDiscordEmpty', d.discord, 'discord');
renderPicker('Mastodon', 'schedMastodonAccounts', 'schedMastodonEmpty', d.mastodon, 'mastodon');
renderPicker('LinkedIn', 'schedLinkedinProfiles', 'schedLinkedinEmpty', d.linkedin, 'linkedin');
}
function renderPicker(name, containerId, emptyId, items, platform) {
const container = document.getElementById(containerId);
const emptyEl = document.getElementById(emptyId);
if (!container) return;
if (!items.length) {
container.innerHTML = '';
emptyEl?.classList.remove('d-none');
return;
}
emptyEl?.classList.add('d-none');
container.innerHTML = items.map(item => `
${esc(item.label)}
`).join('');
}
function togglePicker(checkboxId, pickerId) {
const cb = document.getElementById(checkboxId);
const pk = document.getElementById(pickerId);
if (cb && pk) cb.addEventListener('change', () => pk.classList.toggle('d-none', !cb.checked));
}
togglePicker('schedUseDiscord', 'schedDiscordPicker');
togglePicker('schedUseMastodon', 'schedMastodonPicker');
togglePicker('schedUseLinkedin', 'schedLinkedinPicker');
document.getElementById('schedSubmitBtn').addEventListener('click', async () => {
const content = document.getElementById('schedContent').value.trim();
const url = document.getElementById('schedUrl').value.trim();
const at = document.getElementById('schedAt').value;
const errEl = document.getElementById('schedError');
errEl.classList.add('d-none');
if (!content) { errEl.textContent = 'Content is required'; errEl.classList.remove('d-none'); return; }
if (!at) { errEl.textContent = 'Scheduled time is required'; errEl.classList.remove('d-none'); return; }
const targets = [];
panel.querySelectorAll('.sched-target-check:checked').forEach(cb => {
targets.push({ platform: cb.dataset.platform, target: cb.dataset.target });
});
if (!targets.length) { errEl.textContent = 'Select at least one platform target'; errEl.classList.remove('d-none'); return; }
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'create', content, url, scheduled_at: at, targets }),
});
const d = await r.json();
if (d.error) { errEl.textContent = d.error; errEl.classList.remove('d-none'); return; }
showSuccess('Post scheduled');
document.getElementById('schedContent').value = '';
document.getElementById('schedUrl').value = '';
document.getElementById('schedAt').value = '';
panel.querySelectorAll('.sched-target-check').forEach(cb => cb.checked = false);
['schedDiscordPicker', 'schedMastodonPicker', 'schedLinkedinPicker'].forEach(id => document.getElementById(id)?.classList.add('d-none'));
['schedUseDiscord', 'schedUseMastodon', 'schedUseLinkedin'].forEach(id => { const el = document.getElementById(id); if (el) el.checked = false; });
loadPosts();
});
async function loadPosts() {
const r = await fetch('/api/post_schedule?project_id=' + pid);
const d = await r.json();
const list = document.getElementById('schedPostsList');
if (d.error) { list.innerHTML = `${esc(d.error)}
`; return; }
if (!d.posts.length) { list.innerHTML = 'No scheduled posts yet.
'; return; }
const statusBadge = s => {
const map = { pending: 'bg-warning text-dark', processing: 'bg-info text-dark', done: 'bg-success', partial: 'bg-danger', sent: 'bg-success', failed: 'bg-danger' };
return `${esc(s)} `;
};
const platformIcon = p => ({ discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' }[p] || 'bi-dot');
list.innerHTML = d.posts.map(post => {
const targets = (post.targets || []).map(t => `
${esc(t.target)}
${statusBadge(t.status)}
${t.error ? ` ` : ''}
${t.status === 'failed' ? `Retry ` : ''}
`).join('');
return `
${esc(post.content.substring(0, 120))}${post.content.length > 120 ? '…' : ''}
${post.url ? `
${esc(post.url)} ` : ''}
${statusBadge(post.status)}
${esc(post.scheduled_at)}
${targets}
`;
}).join('');
list.querySelectorAll('.del-post-btn').forEach(b => b.addEventListener('click', () => {
confirmAction('Delete this scheduled post?', async () => {
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'delete', id: +b.dataset.postId }),
});
const d = await r.json();
if (d.ok) { showSuccess('Post deleted'); loadPosts(); }
else showError(d.error || 'Delete failed');
});
}));
list.querySelectorAll('.retry-target-btn').forEach(b => b.addEventListener('click', async () => {
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'retry_target', target_id: +b.dataset.targetId }),
});
const d = await r.json();
if (d.ok) { showSuccess('Target queued for retry'); loadPosts(); }
else showError(d.error || 'Retry failed');
}));
}
document.getElementById('schedRunNowBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('schedRunNowBtn');
const outBox = document.getElementById('schedRunNowOutput');
const pre = document.getElementById('schedRunNowPre');
btn.disabled = true;
outBox.classList.add('d-none');
const r = await fetch('/api/post_schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: +pid, action: 'run_now' }),
});
const d = await r.json();
btn.disabled = false;
if (d.error) { showError(d.error); return; }
pre.textContent = d.output || '(no output — nothing due)';
outBox.classList.remove('d-none');
loadPosts();
});
loadTargets();
loadPosts();
})();
// ── Bot: log viewer ───────────────────────────────────────────────────────────
(function () {
const panel = document.getElementById('botLogsPanel');
if (!panel) return;
const pid = panel.dataset.projectId;
const pre = document.getElementById('logsOutput');
let _timer = null;
async function loadLogs() {
const lines = document.getElementById('logsLineCount').value;
try {
const r = await fetch(`/api/botlogs?project_id=${pid}&lines=${lines}`);
const d = await r.json();
if (d.error) { pre.textContent = d.error; return; }
pre.textContent = d.lines.join('\n') || '(no log entries)';
pre.scrollTop = pre.scrollHeight;
} catch (e) { pre.textContent = 'Error: ' + e.message; }
}
function startAutoRefresh() {
stopAutoRefresh();
_timer = setInterval(loadLogs, 5000);
}
function stopAutoRefresh() {
if (_timer) { clearInterval(_timer); _timer = null; }
}
document.getElementById('logsRefreshBtn').addEventListener('click', loadLogs);
document.getElementById('logsLineCount').addEventListener('change', loadLogs);
document.getElementById('logsAutoRefresh').addEventListener('change', e => {
e.target.checked ? startAutoRefresh() : stopAutoRefresh();
});
loadLogs();
})();
// ── Social Scheduler ─────────────────────────────────────────────────────────
(function () {
if (!document.getElementById('socialPage')) return;
let _targets = [];
let _filter = 'pending';
const esc = s => String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');
const CHAR_LIMIT = 500;
const AUTOSAVE_DELAY = 2000;
function localDTStr(d) {
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}
function defaultScheduleTime() {
const d = new Date(Date.now() + 5 * 60 * 1000);
d.setSeconds(0, 0);
return localDTStr(d);
}
// ts is stored UTC ("2026-06-04 17:51:00") — parse with Z so JS treats it as UTC
function utcToDate(ts) { return new Date(ts.replace(' ', 'T') + 'Z'); }
// Convert stored UTC string → "YYYY-MM-DDTHH:MM" in browser local time (for datetime-local input)
function utcToLocalInput(ts) { return localDTStr(utcToDate(ts)); }
// Convert datetime-local value (local time) → "YYYY-MM-DD HH:MM:00" UTC for the server
function localInputToUTC(dtLocal) {
const d = new Date(dtLocal); // browser parses datetime-local as local time
const p = n => String(n).padStart(2, '0');
return `${d.getUTCFullYear()}-${p(d.getUTCMonth()+1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:00`;
}
function relTime(ts) {
const diff = (utcToDate(ts) - Date.now()) / 1000;
const abs = Math.abs(diff);
const suf = diff > 0 ? '' : ' ago';
const pre = diff > 0 ? 'in ' : '';
if (abs < 60) return 'just now';
if (abs < 3600) return pre + Math.round(abs / 60) + ' min' + suf;
if (abs < 86400) return pre + Math.round(abs / 3600) + ' h' + suf;
return pre + Math.round(abs / 86400) + ' d' + suf;
}
function fmtDT(ts) {
return localDTStr(utcToDate(ts)).replace('T', ' ');
}
// ── Emoji picker ─────────────────────────────────────────────────────────────
const emojiPanel = document.getElementById('socialEmojiPanel');
let _emojiTA = null;
document.querySelectorAll('#socialEmojiPanel .social-emoji-grid > div:not(.social-emoji-cat)').forEach(row => {
row.innerHTML = row.textContent.trim().split(/\s+/).map(e => `${e} `).join(' ');
});
emojiPanel.querySelector('.social-emoji-grid').addEventListener('click', e => {
const ch = e.target.closest('.emoji-btn')?.dataset.e;
if (!ch || !_emojiTA) return;
const ta = _emojiTA, s = ta.selectionStart, end = ta.selectionEnd;
ta.value = ta.value.slice(0, s) + ch + ta.value.slice(end);
ta.selectionStart = ta.selectionEnd = s + ch.length;
ta.dispatchEvent(new Event('input'));
emojiPanel.classList.add('d-none');
ta.focus();
});
document.addEventListener('click', e => {
if (!emojiPanel.classList.contains('d-none') &&
!e.target.closest('#socialEmojiPanel') &&
!e.target.closest('.sc-emoji-btn')) {
emojiPanel.classList.add('d-none');
}
});
function showEmojiPanel(btn, ta) {
if (!emojiPanel.classList.contains('d-none') && _emojiTA === ta) {
emojiPanel.classList.add('d-none'); return;
}
_emojiTA = ta;
const r = btn.getBoundingClientRect();
emojiPanel.style.top = (r.bottom + 4) + 'px';
emojiPanel.style.left = Math.min(r.left, window.innerWidth - 280) + 'px';
emojiPanel.classList.remove('d-none');
}
// ── Targets ──────────────────────────────────────────────────────────────────
async function loadTargets() {
const r = await fetch('/api/social?action=targets');
const d = await r.json();
_targets = d.groups || [];
}
function renderTargetsInto(container, prefill, hasImage) {
if (!_targets.length) {
container.innerHTML = 'No platforms configured.
Settings → ';
return;
}
const pills = _targets.flatMap(group =>
['discord', 'mastodon', 'linkedin'].flatMap(platform =>
(group.targets[platform] || []).map(t => {
const active = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key);
const imgOn = prefill?.some(x => x.project_id == group.project_id && x.platform === platform && x.target_key === t.key && x.include_image);
return `
${esc(t.label)}
`;
})
)
);
container.innerHTML = `${pills.join('')}
`;
container.querySelectorAll('.social-pill').forEach(pill => {
pill.addEventListener('click', e => {
if (e.target.closest('.social-pill-img')) {
if (pill.classList.contains('active')) pill.classList.toggle('img-on');
return;
}
pill.classList.toggle('active');
const imgVisible = !container.querySelector('.social-pill-img.d-none');
if (pill.classList.contains('active') && imgVisible) pill.classList.add('img-on');
else if (!pill.classList.contains('active')) pill.classList.remove('img-on');
});
});
}
function collectTargetsFrom(container) {
const out = [];
container.querySelectorAll('.social-pill.active').forEach(pill => {
out.push({
project_id: +pill.dataset.pid,
platform: pill.dataset.platform,
target_key: pill.dataset.key,
include_image: pill.classList.contains('img-on') ? 1 : 0,
});
});
return out;
}
function toggleImageInCard(card, show) {
card.querySelectorAll('.social-pill-img').forEach(el => el.classList.toggle('d-none', !show));
if (show) card.querySelectorAll('.social-pill.active').forEach(p => p.classList.add('img-on'));
if (!show) card.querySelectorAll('.social-pill').forEach(p => p.classList.remove('img-on'));
}
// ── Shared image upload ───────────────────────────────────────────────────────
const imgInput = document.getElementById('socialImageInput');
let _uploadCard = null;
imgInput.addEventListener('change', async function () {
const card = _uploadCard;
if (!this.files[0] || !card) return;
const fd = new FormData();
fd.append('image', this.files[0]);
this.value = '';
const r = await fetch('/api/social_upload', { method: 'POST', body: fd });
const d = await r.json();
if (d.error) { showCardError(card, d.error); return; }
card.querySelector('.sc-img-path').value = d.path;
card.querySelector('.sc-img-thumb').src = d.url;
card.querySelector('.sc-img-preview').classList.remove('d-none');
card.querySelector('.sc-remove-img-btn').classList.remove('d-none');
toggleImageInCard(card, true);
scheduleAutosave(card);
});
// ── Status badge ─────────────────────────────────────────────────────────────
const STATUS_BADGE = s => {
const m = { pending: ['warning text-dark','clock'], sent: ['success','check-circle'], done: ['success','check-circle-fill'], failed: ['danger','x-circle'], partial: ['warning text-dark','exclamation-circle'], processing: ['info text-dark','arrow-repeat'] };
const [cls, icon] = m[s] || ['secondary','dot'];
return `${esc(s)} `;
};
// ── Build view pane HTML ──────────────────────────────────────────────────────
function buildViewPane(post) {
const platformIcon = { discord: 'bi-discord', mastodon: 'bi-mastodon', linkedin: 'bi-linkedin' };
const canRetry = post.targets?.some(t => t.status === 'failed');
const platBadges = (post.targets || []).map(t => {
const sCls = { sent: 'bg-success', done: 'bg-success', failed: 'bg-danger', pending: 'bg-warning text-dark' };
return `
${esc(t.target_key)}
${esc(t.status)}
${t.error ? ` ` : ''}
`;
}).join(' ');
return `
${esc(fmtDT(post.scheduled_at))} (${relTime(post.scheduled_at)})
${STATUS_BADGE(post.status)}
${canRetry ? ` ` : ''}
${esc(post.content)}
${post.image_url ? `
` : ''}
${platBadges}
`;
}
// ── Build edit pane HTML ──────────────────────────────────────────────────────
function buildEditPane(post) {
const isNew = !post;
const dt = post ? utcToLocalInput(post.scheduled_at) : defaultScheduleTime();
const content = post?.content || '';
const imgPath = post?.image_path || '';
const imgUrl = post?.image_url || '';
return `
😊
${[...content].length} / ${CHAR_LIMIT}
${!isNew ? 'Cancel ' : ''}
${isNew ? ' Schedule' : ' Save'}
`;
}
// ── Autosave ──────────────────────────────────────────────────────────────────
function setSaveIndicator(card, state) {
const el = card.querySelector('.sc-save-indicator');
if (!el) return;
const map = { dirty: ['●', 'text-muted'], saving: ['saving…', 'text-info small'], saved: ['✓ saved', 'text-success small'], error: ['✗ error', 'text-danger small'] };
const [txt, cls] = map[state] || ['', ''];
el.textContent = txt;
el.className = 'sc-save-indicator ' + cls;
if (state === 'saved') setTimeout(() => { if (el.textContent === '✓ saved') el.textContent = ''; }, 2000);
}
function scheduleAutosave(card) {
if (!card._post) return; // don't autosave new post card
setSaveIndicator(card, 'dirty');
clearTimeout(card._autosaveTimer);
card._autosaveTimer = setTimeout(() => doAutosave(card), AUTOSAVE_DELAY);
}
async function doAutosave(card) {
const post = card._post;
if (!post) return;
const content = card.querySelector('.sc-content').value.trim();
const scheduledAt = card.querySelector('.sc-dt').value;
const imagePath = card.querySelector('.sc-img-path').value;
const targets = collectTargetsFrom(card.querySelector('.sc-targets'));
if (!content || !scheduledAt || !targets.length) return;
setSaveIndicator(card, 'saving');
const scheduledUTC = localInputToUTC(scheduledAt);
const body = { action: 'update', id: post.id, content, scheduled_at: scheduledUTC, image_path: imagePath, targets };
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); }
else {
setSaveIndicator(card, 'saved');
card._post = { ...post, content, scheduled_at: scheduledUTC, image_path: imagePath };
}
}
// ── Init edit card ────────────────────────────────────────────────────────────
function initEditCard(card, post) {
card._post = post || null;
const textarea = card.querySelector('.sc-content');
const charCount = card.querySelector('.sc-char-count');
textarea.addEventListener('input', () => {
const n = [...textarea.value].length;
charCount.textContent = `${n} / ${CHAR_LIMIT}`;
charCount.className = 'small ' + (n > CHAR_LIMIT ? 'social-char-over' : n > CHAR_LIMIT * 0.9 ? 'social-char-warn' : 'text-muted');
scheduleAutosave(card);
});
card.querySelector('.sc-dt').addEventListener('change', () => scheduleAutosave(card));
card.querySelector('.sc-emoji-btn').addEventListener('click', e => {
e.stopPropagation();
showEmojiPanel(e.currentTarget, textarea);
});
card.querySelector('.sc-upload-btn').addEventListener('click', () => {
_uploadCard = card;
imgInput.click();
});
card.querySelector('.sc-remove-img-btn').addEventListener('click', () => {
card.querySelector('.sc-img-path').value = '';
card.querySelector('.sc-img-thumb').src = '';
card.querySelector('.sc-img-preview').classList.add('d-none');
card.querySelector('.sc-remove-img-btn').classList.add('d-none');
toggleImageInCard(card, false);
scheduleAutosave(card);
});
card.querySelector('.sc-save-btn').addEventListener('click', () => submitCard(card, post));
card.querySelector('.sc-cancel-btn')?.addEventListener('click', () => {
clearTimeout(card._autosaveTimer);
exitEditMode(card);
});
const targetsDiv = card.querySelector('.sc-targets');
renderTargetsInto(targetsDiv, post?.targets || null, !!card.querySelector('.sc-img-path').value);
// wire target pill changes to autosave
targetsDiv.addEventListener('click', () => scheduleAutosave(card));
}
// ── Init view card ────────────────────────────────────────────────────────────
function initViewCard(card, post) {
card._post = post;
card.querySelector('.sc-view-pane').addEventListener('click', e => {
if (e.target.closest('button')) return;
enterEditMode(card, post);
});
card.querySelector('.sc-edit-btn').addEventListener('click', () => enterEditMode(card, post));
card.querySelector('.sc-dup-btn').addEventListener('click', () => duplicatePost(post.id));
card.querySelector('.sc-retry-btn')?.addEventListener('click', async () => {
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'retry', id: post.id }) });
const d = await r.json();
if (d.ok) loadPosts();
});
const delBtn = card.querySelector('.sc-del-btn');
delBtn.addEventListener('click', async () => {
if (!delBtn._confirm) {
delBtn._confirm = true;
delBtn.innerHTML = 'Sure?';
delBtn.classList.replace('btn-outline-danger', 'btn-danger');
setTimeout(() => {
if (delBtn._confirm) {
delBtn._confirm = false;
delBtn.innerHTML = ' ';
delBtn.classList.replace('btn-danger', 'btn-outline-danger');
}
}, 3000);
return;
}
delBtn._confirm = false;
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'delete', id: post.id }) });
const d = await r.json();
if (d.ok) card.closest('[class*="col-"]').remove();
else showCardError(card, d.error);
});
}
// ── Enter / exit edit mode ────────────────────────────────────────────────────
function enterEditMode(card, post) {
card._post = post;
card.classList.add('sc-editing');
card.innerHTML = buildEditPane(post);
initEditCard(card, post);
constrainImages();
}
function exitEditMode(card) {
const post = card._post;
card.classList.remove('sc-editing');
card.innerHTML = buildViewPane(post);
initViewCard(card, post);
constrainImages();
}
// ── Submit (explicit save) ────────────────────────────────────────────────────
async function submitCard(card, post) {
const content = card.querySelector('.sc-content').value.trim();
const scheduledAt = card.querySelector('.sc-dt').value;
const imagePath = card.querySelector('.sc-img-path').value;
const targets = collectTargetsFrom(card.querySelector('.sc-targets'));
const isNew = !post;
hideCardError(card);
if (!content) { showCardError(card, 'Content is required'); return; }
if (!scheduledAt) { showCardError(card, 'Scheduled time is required'); return; }
if (!targets.length) { showCardError(card, 'Select at least one platform target'); return; }
clearTimeout(card._autosaveTimer);
setSaveIndicator(card, 'saving');
const body = { action: isNew ? 'create' : 'update', content, scheduled_at: localInputToUTC(scheduledAt), image_path: imagePath, targets };
if (!isNew) body.id = post.id;
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const d = await r.json();
if (d.error) { setSaveIndicator(card, 'error'); showCardError(card, d.error); return; }
if (isNew) {
// reset new-post card
card.innerHTML = buildEditPane(null);
initEditCard(card, null);
}
loadPosts();
}
// ── Duplicate ─────────────────────────────────────────────────────────────────
async function duplicatePost(id) {
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'duplicate', id }) });
const d = await r.json();
if (d.error) { return; }
const newCard = document.querySelector('.sc-new-card');
if (!newCard) return;
newCard.innerHTML = buildEditPane({ ...d.post, scheduled_at: '', targets: d.targets || [] });
newCard.querySelector('.sc-dt').value = '';
initEditCard(newCard, null);
newCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── Grid render ───────────────────────────────────────────────────────────────
function renderGrid(posts) {
const grid = document.getElementById('socialPostsGrid');
grid.innerHTML = '';
// "new post" card — always first
const newCol = document.createElement('div');
newCol.className = 'col-xl-4 col-md-6 col-12';
newCol.innerHTML = `${buildEditPane(null)}
`;
grid.appendChild(newCol);
initEditCard(newCol.querySelector('.sc-card'), null);
if (!posts.length) {
const emptyCol = document.createElement('div');
emptyCol.className = 'col-12 d-flex align-items-center text-muted small py-4 ps-3';
emptyCol.innerHTML = ` ${_filter === 'pending' ? 'No pending posts.' : 'No posts yet.'}`;
grid.appendChild(emptyCol);
} else {
posts.forEach(post => {
const col = document.createElement('div');
col.className = 'col-xl-4 col-md-6 col-12';
col.innerHTML = `${buildViewPane(post)}
`;
grid.appendChild(col);
initViewCard(col.querySelector('.sc-card'), post);
});
}
constrainImages();
}
function constrainImages() {
document.querySelectorAll('.sc-img-wrap').forEach(wrap => {
const w = wrap.clientWidth || wrap.parentElement?.clientWidth;
if (w) wrap.style.maxHeight = w + 'px';
});
}
// ── Load posts ────────────────────────────────────────────────────────────────
async function loadPosts() {
const r = await fetch('/api/social?status=' + _filter);
const d = await r.json();
const grid = document.getElementById('socialPostsGrid');
if (d.error) {
grid.innerHTML = ``;
return;
}
renderGrid(d.posts);
}
// ── Filters ───────────────────────────────────────────────────────────────────
document.getElementById('socialFilterPending').addEventListener('click', function () {
_filter = 'pending';
this.classList.add('active');
document.getElementById('socialFilterAll').classList.remove('active');
loadPosts();
});
document.getElementById('socialFilterAll').addEventListener('click', function () {
_filter = 'all';
this.classList.add('active');
document.getElementById('socialFilterPending').classList.remove('active');
loadPosts();
});
document.getElementById('socialRefreshBtn').addEventListener('click', loadPosts);
document.getElementById('socialDispatchBtn').addEventListener('click', async function () {
this.disabled = true;
this.innerHTML = ' Running…';
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'dispatch' }) });
const d = await r.json();
this.disabled = false;
this.innerHTML = ' Dispatch now';
const out = d.output?.trim();
const msg = out || 'No pending posts were due (or all sent silently).';
const box = document.getElementById('socialDispatchOut');
box.textContent = msg;
box.classList.remove('d-none');
loadPosts();
});
// ── Per-card error helpers ────────────────────────────────────────────────────
function showCardError(card, msg) {
const el = card.querySelector('.sc-error');
if (!el) return;
el.textContent = msg;
el.classList.remove('d-none');
}
function hideCardError(card) {
card.querySelector('.sc-error')?.classList.add('d-none');
}
// Init
loadTargets().then(() => loadPosts());
})();
// ── Settings: social eligible projects ──────────────────────────────────────
(function () {
const list = document.getElementById('socialProjectsList');
if (!list) return;
async function loadSocialSettings() {
const r = await fetch('/api/social?action=settings');
const d = await r.json();
const enabled = d.enabled || [];
if (!d.projects.length) {
list.innerHTML = 'No Discord Bot projects found. Add one via project scan first.
';
return;
}
list.innerHTML = d.projects.map(p => `
${p.name}
`).join('');
}
document.getElementById('saveSocialProjectsBtn').addEventListener('click', async () => {
const ids = [...list.querySelectorAll('.social-proj-cb:checked')].map(cb => +cb.value);
const r = await fetch('/api/social', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'save_settings', project_ids: ids }) });
const d = await r.json();
if (d.ok) {
const saved = document.getElementById('socialProjectsSaved');
saved.classList.remove('d-none');
setTimeout(() => saved.classList.add('d-none'), 2000);
}
});
loadSocialSettings();
})();