feat: add author selection when publishing to Ghost
All checks were successful
Deploy to Production / deploy (push) Successful in 2m6s
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:
parent
a2a011dc8c
commit
3e3b314407
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 || [];
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user