feat(blog): Post to Ghost + LinkedIn buttons in dashboard

- 'Post on blog.fichtmueller.org' → publishes via Ghost Admin API
- 'Post on LinkedIn' → modal with text + copy + open LinkedIn
- Ghost integration: TIP Blog Engine (JWT auth, mobiledoc format)
This commit is contained in:
Rene Fichtmueller 2026-04-05 23:33:58 +02:00
parent 3913372f10
commit 80435f8e07
2 changed files with 162 additions and 1 deletions

View File

@ -1874,6 +1874,94 @@ blogRouter.post("/:id/regenerate", async (req: Request, res: Response) => {
});
// DELETE /api/blog/:id — Delete a blog draft
// POST /api/blog/:id/publish-ghost — Publish to blog.fichtmueller.org via Ghost Admin API
blogRouter.post("/:id/publish-ghost", async (req: Request, res: Response) => {
try {
const draft = await pool.query(
"SELECT id, title, draft_content, seo_keywords FROM blog_drafts WHERE id = $1::uuid",
[req.params.id]
);
if (draft.rows.length === 0) {
return res.status(404).json({ success: false, error: "Draft not found" });
}
const { title, draft_content, seo_keywords } = draft.rows[0];
if (!draft_content || draft_content.trim().length < 100) {
return res.status(400).json({ success: false, error: "Draft content too short to publish" });
}
// Ghost Admin API JWT auth
const GHOST_URL = process.env.GHOST_URL || "https://blog.fichtmueller.org";
const GHOST_ADMIN_KEY = process.env.GHOST_ADMIN_KEY || "87727de2746a4de69efd5b03:7abdbec3a7ae473ad09487fc6e48327809da27c8adaaea457cce2d4f55b065f7";
const [keyId, secret] = GHOST_ADMIN_KEY.split(":");
// Create JWT token for Ghost Admin API
const crypto = await import("crypto");
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT", kid: keyId })).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const payload = Buffer.from(JSON.stringify({
iat: now, exp: now + 300, aud: "/admin/"
})).toString("base64url");
const signature = crypto.createHmac("sha256", Buffer.from(secret, "hex"))
.update(`${header}.${payload}`).digest("base64url");
const jwt = `${header}.${payload}.${signature}`;
// Convert markdown content to Ghost mobiledoc
// Strip the # Title from content (Ghost uses its own title field)
const bodyContent = draft_content.replace(/^#\s+[^\n]+\n*/m, "").trim();
// Build mobiledoc with markdown card
const mobiledoc = JSON.stringify({
version: "0.3.1",
ghostVersion: "4.0",
markups: [], atoms: [], sections: [[10, 0]], cards: [["markdown", { markdown: bodyContent }]]
});
// Build tags from seo_keywords
const tags = (seo_keywords || "").split(",").map((k: string) => k.trim()).filter(Boolean).slice(0, 5)
.map((t: string) => ({ name: t }));
// POST to Ghost Admin API
const ghostRes = await fetch(`${GHOST_URL}/ghost/api/admin/posts/?source=html`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept-Version": "v5.0",
Authorization: `Ghost ${jwt}`,
},
body: JSON.stringify({
posts: [{
title,
mobiledoc,
status: "published",
tags: tags.length > 0 ? tags : [{ name: "Optical Networking" }],
}]
}),
});
if (!ghostRes.ok) {
const errBody = await ghostRes.text();
console.error("[blog] Ghost publish failed:", ghostRes.status, errBody.slice(0, 300));
return res.status(500).json({ success: false, error: `Ghost API error: ${ghostRes.status}` });
}
const ghostData = await ghostRes.json() as { posts?: Array<{ url?: string; slug?: string }> };
const ghostUrl = ghostData.posts?.[0]?.url || `${GHOST_URL}/`;
// Update TIP draft status
await pool.query(
"UPDATE blog_drafts SET status = 'published', updated_at = NOW() WHERE id = $1::uuid",
[req.params.id]
);
console.log(`[blog] Published to Ghost: ${title}${ghostUrl}`);
res.json({ success: true, url: ghostUrl, ghost_slug: ghostData.posts?.[0]?.slug });
} catch (err) {
console.error("[blog] Ghost publish error:", err);
res.status(500).json({ success: false, error: (err as Error).message });
}
});
blogRouter.delete("/:id", async (req: Request, res: Response) => {
try {
// Delete feedback first (FK constraint)

View File

@ -4384,7 +4384,7 @@ async function openBlogDetail(id) {
}
h += '</div>';
if (d.linkedin_post) {
h += '<div style="font-size:0.85rem;color:var(--text);line-height:1.7;white-space:pre-wrap;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + esc(d.linkedin_post) + '</div>';
h += '<div data-linkedin-text="' + esc(d.linkedin_post).replace(/"/g, '&quot;') + '" style="font-size:0.85rem;color:var(--text);line-height:1.7;white-space:pre-wrap;background:var(--surface2);padding:1.2rem;border-radius:var(--radius-lg);border:1px solid var(--border)">' + esc(d.linkedin_post) + '</div>';
} else {
h += '<div style="font-size:0.8rem;color:var(--text-dim);padding:0.8rem;background:var(--surface2);border-radius:var(--radius-md);border:1px solid var(--border)">No LinkedIn post yet — regenerate to produce one.</div>';
}
@ -4403,6 +4403,13 @@ async function openBlogDetail(id) {
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'approved\')" style="color:var(--green);border-color:rgba(45,106,79,0.3)">Approve</button>';
h += '<button class="btn-ghost" onclick="updateBlogStatus(\'' + esc(d.id) + '\',\'published\')" style="color:var(--accent);border-color:rgba(196,112,75,0.3)">Publish</button>';
h += '</div>';
// Publishing actions
h += '<div style="margin-top:0.75rem;display:flex;gap:0.5rem;flex-wrap:wrap">';
h += '<button class="btn-ghost" onclick="postToGhost(\'' + esc(d.id) + '\')" style="color:#2D7A50;border-color:rgba(45,122,80,0.4);font-weight:600">&#9997; Post on blog.fichtmueller.org</button>';
if (d.linkedin_post) {
h += '<button class="btn-ghost" onclick="openLinkedInPost(\'' + esc(d.id) + '\')" style="color:#0A66C2;border-color:rgba(10,102,194,0.4);font-weight:600">&#x1F517; Post on LinkedIn</button>';
}
h += '</div>';
buildDOM(el('panel-content'), h);
} catch(e) { el('panel-content').textContent = 'Error: ' + e.message; }
}
@ -4422,6 +4429,72 @@ async function updateBlogStatus(id, status) {
} catch(e) { showToast('Error', e.message, true); }
}
async function postToGhost(id) {
try {
showToast('Publishing…', 'Posting to blog.fichtmueller.org');
var data = await api('/api/blog/' + id + '/publish-ghost', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (data.success) {
showToast('Published!', 'Live at: ' + (data.url || 'blog.fichtmueller.org'));
updateBlogStatus(id, 'published');
} else {
showToast('Failed', data.error || 'Ghost publish failed', true);
}
} catch(e) { showToast('Error', e.message, true); }
}
function openLinkedInPost(id) {
// Find cached blog data or fetch it
var panel = el('panel-content');
// Extract linkedin_post from the panel DOM (it's rendered there already)
var liSection = panel.querySelector('[data-linkedin-text]');
var liText = liSection ? liSection.getAttribute('data-linkedin-text') : '';
if (!liText) {
// Fallback: fetch from API
api('/api/blog/' + id).then(function(d) {
if (d && d.linkedin_post) showLinkedInModal(d.linkedin_post, d.title);
else showToast('No LinkedIn text', 'Generate the blog first', true);
});
return;
}
showLinkedInModal(liText, '');
}
function showLinkedInModal(text, title) {
// Remove existing modal if any
var existing = document.getElementById('linkedin-modal');
if (existing) existing.remove();
var modal = document.createElement('div');
modal.id = 'linkedin-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center';
modal.onclick = function(e) { if (e.target === modal) modal.remove(); };
var box = document.createElement('div');
box.style.cssText = 'background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:1.5rem;max-width:520px;width:90%;max-height:80vh;display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.3)';
var header = '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">'
+ '<div style="display:flex;align-items:center;gap:0.5rem"><span style="color:#0A66C2;font-size:1.2rem">&#x1F517;</span><span style="font-weight:700;color:var(--text-bright)">LinkedIn Post</span></div>'
+ '<button onclick="document.getElementById(\'linkedin-modal\').remove()" style="background:none;border:none;color:var(--text-dim);cursor:pointer;font-size:1.2rem">&times;</button>'
+ '</div>';
var textarea = '<textarea id="linkedin-textarea" style="width:100%;min-height:250px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;padding:0.75rem;color:var(--text);font-family:var(--body);font-size:0.85rem;resize:vertical;line-height:1.5" readonly>' + text.replace(/</g, '&lt;') + '</textarea>';
var charCount = '<div style="text-align:right;font-size:0.7rem;color:var(--text-dim);margin-top:0.3rem">' + text.length + ' / 3,000 chars</div>';
var buttons = '<div style="display:flex;gap:0.5rem;margin-top:0.75rem">'
+ '<button onclick="navigator.clipboard.writeText(document.getElementById(\'linkedin-textarea\').value).then(function(){showToast(\'Copied\',\'LinkedIn text copied to clipboard\')})" style="flex:1;padding:0.5rem;background:var(--surface2);border:1px solid var(--border);border-radius:6px;color:var(--text);cursor:pointer;font-size:0.8rem">&#128203; Copy Text</button>'
+ '<button onclick="window.open(\'https://www.linkedin.com/feed/?shareActive=true\',\'_blank\')" style="flex:1;padding:0.5rem;background:#0A66C2;border:none;border-radius:6px;color:#fff;cursor:pointer;font-size:0.8rem;font-weight:600">Open LinkedIn</button>'
+ '</div>';
box.innerHTML = header + textarea + charCount + buttons;
modal.appendChild(box);
document.body.appendChild(modal);
}
async function regenerateBlog(id) {
showToast('Regenerating…', 'LLM pipeline wird neu gestartet');
try {