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,
|
streamingContent,
|
||||||
tokenCount,
|
tokenCount,
|
||||||
generationError,
|
generationError,
|
||||||
|
selectedAuthor,
|
||||||
// setters
|
// setters
|
||||||
setDraft,
|
setDraft,
|
||||||
setMeta,
|
setMeta,
|
||||||
@ -51,6 +52,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
setStreamingContent,
|
setStreamingContent,
|
||||||
setTokenCount,
|
setTokenCount,
|
||||||
setGenerationError,
|
setGenerationError,
|
||||||
|
setSelectedAuthor,
|
||||||
// actions
|
// actions
|
||||||
savePost,
|
savePost,
|
||||||
deletePost,
|
deletePost,
|
||||||
@ -233,6 +235,8 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
|||||||
draftHtml={draft}
|
draftHtml={draft}
|
||||||
onRefreshPreview={refreshPreview}
|
onRefreshPreview={refreshPreview}
|
||||||
onGhostPublish={publishToGhost}
|
onGhostPublish={publishToGhost}
|
||||||
|
selectedAuthor={selectedAuthor}
|
||||||
|
onAuthorChange={setSelectedAuthor}
|
||||||
/>
|
/>
|
||||||
</StepContainer>
|
</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 StepHeader from './StepHeader';
|
||||||
|
import { getGhostAuthors, type GhostAuthor } from '../../services/ghost';
|
||||||
|
|
||||||
export default function StepPublish({
|
export default function StepPublish({
|
||||||
previewLoading,
|
previewLoading,
|
||||||
@ -8,6 +10,8 @@ export default function StepPublish({
|
|||||||
draftHtml,
|
draftHtml,
|
||||||
onRefreshPreview,
|
onRefreshPreview,
|
||||||
onGhostPublish,
|
onGhostPublish,
|
||||||
|
selectedAuthor,
|
||||||
|
onAuthorChange,
|
||||||
}: {
|
}: {
|
||||||
previewLoading: boolean;
|
previewLoading: boolean;
|
||||||
previewError: string | null;
|
previewError: string | null;
|
||||||
@ -15,16 +19,72 @@ export default function StepPublish({
|
|||||||
draftHtml: string;
|
draftHtml: string;
|
||||||
onRefreshPreview: () => void;
|
onRefreshPreview: () => void;
|
||||||
onGhostPublish: (status: 'draft' | 'published') => 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 (
|
return (
|
||||||
<Box sx={{ display: 'grid', gap: 1 }}>
|
<Box sx={{ display: 'grid', gap: 1 }}>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
title="Publish"
|
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."
|
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>
|
<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>
|
</Stack>
|
||||||
|
{authorsError && (
|
||||||
|
<Alert severity="warning" sx={{ mt: 1 }}>{authorsError}</Alert>
|
||||||
|
)}
|
||||||
{previewLoading && (
|
{previewLoading && (
|
||||||
<Box sx={{ p: 2, border: '1px dashed', borderColor: 'divider', borderRadius: 1 }}>Generating preview…</Box>
|
<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 [streamingContent, setStreamingContent] = useState('');
|
||||||
const [tokenCount, setTokenCount] = useState(0);
|
const [tokenCount, setTokenCount] = useState(0);
|
||||||
const [generationError, setGenerationError] = useState<string>('');
|
const [generationError, setGenerationError] = useState<string>('');
|
||||||
|
const [selectedAuthor, setSelectedAuthor] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
|
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
|
||||||
@ -104,6 +105,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
canonical_url: meta.canonicalUrl || null,
|
canonical_url: meta.canonicalUrl || null,
|
||||||
tags,
|
tags,
|
||||||
status,
|
status,
|
||||||
|
authors: selectedAuthor ? [selectedAuthor] : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update post status after successful Ghost publish
|
// Update post status after successful Ghost publish
|
||||||
@ -203,6 +205,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
streamingContent,
|
streamingContent,
|
||||||
tokenCount,
|
tokenCount,
|
||||||
generationError,
|
generationError,
|
||||||
|
selectedAuthor,
|
||||||
// setters
|
// setters
|
||||||
setDraft,
|
setDraft,
|
||||||
setMeta,
|
setMeta,
|
||||||
@ -218,6 +221,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
|||||||
setStreamingContent,
|
setStreamingContent,
|
||||||
setTokenCount,
|
setTokenCount,
|
||||||
setGenerationError,
|
setGenerationError,
|
||||||
|
setSelectedAuthor,
|
||||||
// actions
|
// actions
|
||||||
savePost,
|
savePost,
|
||||||
deletePost,
|
deletePost,
|
||||||
|
|||||||
@ -1,5 +1,12 @@
|
|||||||
export type GhostPostStatus = 'draft' | 'published';
|
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 }) {
|
export async function ghostPreview(payload: { html: string; feature_image?: string }) {
|
||||||
const res = await fetch('/api/ghost/preview', {
|
const res = await fetch('/api/ghost/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -17,6 +24,7 @@ export async function ghostPublish(payload: {
|
|||||||
canonical_url?: string | null;
|
canonical_url?: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
status: GhostPostStatus;
|
status: GhostPostStatus;
|
||||||
|
authors?: string[];
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch('/api/ghost/post', {
|
const res = await fetch('/api/ghost/post', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -26,3 +34,10 @@ export async function ghostPublish(payload: {
|
|||||||
if (!res.ok) throw new Error(await res.text());
|
if (!res.ok) throw new Error(await res.text());
|
||||||
return res.json();
|
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,
|
feature_image,
|
||||||
canonical_url,
|
canonical_url,
|
||||||
status,
|
status,
|
||||||
|
authors,
|
||||||
} = req.body as {
|
} = req.body as {
|
||||||
id?: string;
|
id?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -52,6 +53,7 @@ router.post('/post', async (req, res) => {
|
|||||||
feature_image?: string;
|
feature_image?: string;
|
||||||
canonical_url?: string;
|
canonical_url?: string;
|
||||||
status?: 'draft' | 'published';
|
status?: 'draft' | 'published';
|
||||||
|
authors?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!title || !html) {
|
if (!title || !html) {
|
||||||
@ -154,6 +156,7 @@ router.post('/post', async (req, res) => {
|
|||||||
tags: tags && Array.isArray(tags) ? tags : [],
|
tags: tags && Array.isArray(tags) ? tags : [],
|
||||||
...(rewrittenFeatureImage ? { feature_image: rewrittenFeatureImage } : {}),
|
...(rewrittenFeatureImage ? { feature_image: rewrittenFeatureImage } : {}),
|
||||||
...(canonical_url ? { canonical_url } : {}),
|
...(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;
|
export default router;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user