feat: add author selection when publishing to Ghost
All checks were successful
Deploy to Production / deploy (push) Successful in 2m6s

- Added author dropdown menu in publish step to select post author
- Implemented Ghost authors API endpoint to fetch available authors
- Modified post publishing to include selected author in Ghost API payload
- Added author state management in post editor context
- Auto-select first available author when loading author list
- Added loading and error states for author fetching
- Updated types and interfaces to support author data structure
This commit is contained in:
Ender 2025-10-28 14:49:59 +01:00
parent a2a011dc8c
commit 3e3b314407
5 changed files with 130 additions and 2 deletions

View File

@ -37,6 +37,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
streamingContent,
tokenCount,
generationError,
selectedAuthor,
// setters
setDraft,
setMeta,
@ -51,6 +52,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
setStreamingContent,
setTokenCount,
setGenerationError,
setSelectedAuthor,
// actions
savePost,
deletePost,
@ -233,6 +235,8 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
draftHtml={draft}
onRefreshPreview={refreshPreview}
onGhostPublish={publishToGhost}
selectedAuthor={selectedAuthor}
onAuthorChange={setSelectedAuthor}
/>
</StepContainer>
)}

View File

@ -1,5 +1,7 @@
import { Alert, Box, Button, Stack } from '@mui/material';
import { useState, useEffect } from 'react';
import { Alert, Box, Button, Stack, FormControl, InputLabel, Select, MenuItem, CircularProgress } from '@mui/material';
import StepHeader from './StepHeader';
import { getGhostAuthors, type GhostAuthor } from '../../services/ghost';
export default function StepPublish({
previewLoading,
@ -8,6 +10,8 @@ export default function StepPublish({
draftHtml,
onRefreshPreview,
onGhostPublish,
selectedAuthor,
onAuthorChange,
}: {
previewLoading: boolean;
previewError: string | null;
@ -15,16 +19,72 @@ export default function StepPublish({
draftHtml: string;
onRefreshPreview: () => void;
onGhostPublish: (status: 'draft' | 'published') => void;
selectedAuthor?: string;
onAuthorChange?: (authorId: string) => void;
}) {
const [authors, setAuthors] = useState<GhostAuthor[]>([]);
const [loadingAuthors, setLoadingAuthors] = useState(false);
const [authorsError, setAuthorsError] = useState<string | null>(null);
// Load authors on mount
useEffect(() => {
const loadAuthors = async () => {
setLoadingAuthors(true);
setAuthorsError(null);
try {
const data = await getGhostAuthors();
setAuthors(data);
// Auto-select first author if none selected
if (!selectedAuthor && data.length > 0 && onAuthorChange) {
onAuthorChange(data[0].id);
}
} catch (err: any) {
setAuthorsError(err?.message || 'Failed to load authors');
} finally {
setLoadingAuthors(false);
}
};
loadAuthors();
}, []);
return (
<Box sx={{ display: 'grid', gap: 1 }}>
<StepHeader
title="Publish"
description="Preview your post with Ghost media URL rewriting applied. Publish as draft or published to Ghost. Layout may differ from your Ghost theme."
/>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1 }}>
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap', gap: 1, alignItems: 'center' }}>
<Button size="small" variant="outlined" onClick={onRefreshPreview} disabled={previewLoading}>Refresh Preview</Button>
{onAuthorChange && (
<FormControl size="small" sx={{ minWidth: 200 }}>
<InputLabel>Author</InputLabel>
<Select
value={selectedAuthor || ''}
label="Author"
onChange={(e) => onAuthorChange(e.target.value)}
disabled={loadingAuthors}
>
{loadingAuthors ? (
<MenuItem disabled>
<CircularProgress size={20} sx={{ mr: 1 }} />
Loading...
</MenuItem>
) : authors.length === 0 ? (
<MenuItem disabled>No authors found</MenuItem>
) : (
authors.map((author) => (
<MenuItem key={author.id} value={author.id}>
{author.name}
</MenuItem>
))
)}
</Select>
</FormControl>
)}
</Stack>
{authorsError && (
<Alert severity="warning" sx={{ mt: 1 }}>{authorsError}</Alert>
)}
{previewLoading && (
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>Generating preview</Box>
)}

View File

@ -27,6 +27,7 @@ export function usePostEditor(initialPostId?: string | null) {
const [streamingContent, setStreamingContent] = useState('');
const [tokenCount, setTokenCount] = useState(0);
const [generationError, setGenerationError] = useState<string>('');
const [selectedAuthor, setSelectedAuthor] = useState<string | undefined>(undefined);
useEffect(() => {
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
@ -104,6 +105,7 @@ export function usePostEditor(initialPostId?: string | null) {
canonical_url: meta.canonicalUrl || null,
tags,
status,
authors: selectedAuthor ? [selectedAuthor] : undefined,
});
// Update post status after successful Ghost publish
@ -203,6 +205,7 @@ export function usePostEditor(initialPostId?: string | null) {
streamingContent,
tokenCount,
generationError,
selectedAuthor,
// setters
setDraft,
setMeta,
@ -218,6 +221,7 @@ export function usePostEditor(initialPostId?: string | null) {
setStreamingContent,
setTokenCount,
setGenerationError,
setSelectedAuthor,
// actions
savePost,
deletePost,

View File

@ -1,5 +1,12 @@
export type GhostPostStatus = 'draft' | 'published';
export interface GhostAuthor {
id: string;
name: string;
email: string;
slug: string;
}
export async function ghostPreview(payload: { html: string; feature_image?: string }) {
const res = await fetch('/api/ghost/preview', {
method: 'POST',
@ -17,6 +24,7 @@ export async function ghostPublish(payload: {
canonical_url?: string | null;
tags: string[];
status: GhostPostStatus;
authors?: string[];
}) {
const res = await fetch('/api/ghost/post', {
method: 'POST',
@ -26,3 +34,10 @@ export async function ghostPublish(payload: {
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getGhostAuthors(): Promise<GhostAuthor[]> {
const res = await fetch('/api/ghost/authors');
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
return data.authors || [];
}

View File

@ -44,6 +44,7 @@ router.post('/post', async (req, res) => {
feature_image,
canonical_url,
status,
authors,
} = req.body as {
id?: string;
title: string;
@ -52,6 +53,7 @@ router.post('/post', async (req, res) => {
feature_image?: string;
canonical_url?: string;
status?: 'draft' | 'published';
authors?: string[];
};
if (!title || !html) {
@ -154,6 +156,7 @@ router.post('/post', async (req, res) => {
tags: tags && Array.isArray(tags) ? tags : [],
...(rewrittenFeatureImage ? { feature_image: rewrittenFeatureImage } : {}),
...(canonical_url ? { canonical_url } : {}),
...(authors && Array.isArray(authors) && authors.length > 0 ? { authors } : {}),
},
],
};
@ -260,4 +263,46 @@ router.post('/preview', async (req, res) => {
}
});
// Get authors from Ghost
router.get('/authors', async (req, res) => {
try {
const apiUrl = process.env.GHOST_ADMIN_API_URL || '';
const token = getGhostToken();
const base = apiUrl.replace(/\/$/, '');
const url = `${base}/users/?limit=all`;
const ghRes = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'Accept-Version': 'v5.0',
Authorization: `Ghost ${token}`,
},
});
if (!ghRes.ok) {
const text = await ghRes.text();
console.error('Ghost API error fetching authors:', ghRes.status, text);
return res.status(ghRes.status).json({ error: 'Ghost API error', detail: text });
}
const data: any = await ghRes.json();
const users = data?.users || [];
// Return simplified author list
const authors = users.map((user: any) => ({
id: user.id,
name: user.name,
email: user.email,
slug: user.slug,
}));
return res.json({ authors });
} catch (err: any) {
console.error('Fetch authors failed:', err);
return res.status(500).json({ error: 'Fetch authors failed', detail: err?.message || String(err) });
}
});
export default router;