From 65159bd57d505e5a46b131f53d578b49baa6b172 Mon Sep 17 00:00:00 2001 From: Rene Fichtmueller Date: Sun, 5 Apr 2026 23:33:58 +0200 Subject: [PATCH] feat(blog): Post to Ghost + LinkedIn buttons in dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - '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) --- packages/api/src/routes/blog.ts | 88 +++++++++++++++++++++++++++++++++ packages/dashboard/index.html | 75 +++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 1 deletion(-) diff --git a/packages/api/src/routes/blog.ts b/packages/api/src/routes/blog.ts index a83d2e0..dd4a1ab 100644 --- a/packages/api/src/routes/blog.ts +++ b/packages/api/src/routes/blog.ts @@ -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) diff --git a/packages/dashboard/index.html b/packages/dashboard/index.html index f5d50c1..80f8b79 100644 --- a/packages/dashboard/index.html +++ b/packages/dashboard/index.html @@ -4384,7 +4384,7 @@ async function openBlogDetail(id) { } h += ''; if (d.linkedin_post) { - h += '
' + esc(d.linkedin_post) + '
'; + h += '
' + esc(d.linkedin_post) + '
'; } else { h += '
No LinkedIn post yet — regenerate to produce one.
'; } @@ -4403,6 +4403,13 @@ async function openBlogDetail(id) { h += ''; h += ''; h += ''; + // Publishing actions + h += '
'; + h += ''; + if (d.linkedin_post) { + h += ''; + } + h += '
'; 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 = '
' + + '
🔗LinkedIn Post
' + + '' + + '
'; + + var textarea = ''; + + var charCount = '
' + text.length + ' / 3,000 chars
'; + + var buttons = '
' + + '' + + '' + + '
'; + + 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 {