All checks were successful
Deploy to Production / deploy (push) Successful in 2m1s
- Removed canonical URL field from MetadataPanel component and related interfaces - Added URL validation function to ensure feature image and canonical URLs are valid HTTP/HTTPS - Updated Ghost API to skip invalid URLs when publishing posts - Simplified metadata state management by removing canonicalUrl from initial state and update handlers
233 lines
8.6 KiB
TypeScript
233 lines
8.6 KiB
TypeScript
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { getPost, savePost as savePostApi, deletePost as deletePostApi, type SavePostPayload } from '../services/posts';
|
|
import { ghostPreview as ghostPreviewApi, ghostPublish as ghostPublishApi, type GhostPostStatus } from '../services/ghost';
|
|
import type { Metadata } from '../components/MetadataPanel';
|
|
|
|
export function usePostEditor(initialPostId?: string | null) {
|
|
const [postId, setPostId] = useState<string | null>(null);
|
|
const [draft, setDraft] = useState<string>('');
|
|
const [meta, setMeta] = useState<Metadata>({ title: '', tagsText: '', featureImage: '' });
|
|
const [postClips, setPostClips] = useState<Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string; createdAt: string }>>([]);
|
|
const [postStatus, setPostStatus] = useState<'inbox' | 'editing' | 'ready_for_publish' | 'published' | 'archived'>('editing');
|
|
const [promptText, setPromptText] = useState<string>('');
|
|
const [toast, setToast] = useState<{ open: boolean; message: string; severity: 'success' | 'error' } | null>(null);
|
|
const [activeStep, setActiveStep] = useState<number>(0);
|
|
const [previewHtml, setPreviewHtml] = useState<string>('');
|
|
const [previewLoading, setPreviewLoading] = useState<boolean>(false);
|
|
const [previewError, setPreviewError] = useState<string | null>(null);
|
|
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
|
|
const [referenceImageKeys, setReferenceImageKeys] = useState<string[]>([]);
|
|
const [generatedDraft, setGeneratedDraft] = useState<string>('');
|
|
const [imagePlaceholders, setImagePlaceholders] = useState<string[]>([]);
|
|
const [generationSources, setGenerationSources] = useState<Array<{ title: string; url: string }>>([]);
|
|
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
// Streaming state (persisted across navigation)
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
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');
|
|
const savedLocal = localStorage.getItem('voxblog_draft');
|
|
if (savedLocal) setDraft(savedLocal);
|
|
if (savedId) {
|
|
(async () => {
|
|
try {
|
|
const data = await getPost(savedId);
|
|
setDraft(data.contentHtml || '');
|
|
setPostId(data.id || savedId);
|
|
localStorage.setItem('voxblog_post_id', data.id || savedId);
|
|
if (data.contentHtml) localStorage.setItem('voxblog_draft', data.contentHtml);
|
|
if (Array.isArray(data.audioClips)) setPostClips(data.audioClips);
|
|
setMeta(m => ({
|
|
...m,
|
|
title: data.title || '',
|
|
tagsText: data.tagsText || '',
|
|
featureImage: data.featureImage || ''
|
|
}));
|
|
if (data.status) setPostStatus(data.status);
|
|
setPromptText(data.prompt || '');
|
|
if (Array.isArray(data.selectedImageKeys)) setGenImageKeys(data.selectedImageKeys);
|
|
if (Array.isArray(data.referenceImageKeys)) setReferenceImageKeys(data.referenceImageKeys);
|
|
if (data.generatedDraft) setGeneratedDraft(data.generatedDraft);
|
|
if (Array.isArray(data.imagePlaceholders)) setImagePlaceholders(data.imagePlaceholders);
|
|
if (Array.isArray(data.generationSources)) setGenerationSources(data.generationSources);
|
|
} catch {}
|
|
})();
|
|
}
|
|
}, [initialPostId]);
|
|
|
|
const savePost = useCallback(async (overrides?: Partial<SavePostPayload>, silent = false) => {
|
|
localStorage.setItem('voxblog_draft', draft);
|
|
const payload: SavePostPayload = {
|
|
id: postId ?? undefined,
|
|
title: meta.title || undefined,
|
|
contentHtml: draft,
|
|
tags: meta.tagsText ? meta.tagsText.split(',').map(t => t.trim()).filter(Boolean) : undefined,
|
|
featureImage: meta.featureImage || undefined,
|
|
status: postStatus,
|
|
prompt: promptText || undefined,
|
|
selectedImageKeys: genImageKeys.length > 0 ? genImageKeys : undefined,
|
|
referenceImageKeys: referenceImageKeys.length > 0 ? referenceImageKeys : undefined,
|
|
generatedDraft: generatedDraft || undefined,
|
|
imagePlaceholders: imagePlaceholders.length > 0 ? imagePlaceholders : undefined,
|
|
generationSources: generationSources.length > 0 ? generationSources : undefined,
|
|
...(overrides || {}),
|
|
};
|
|
const data = await savePostApi(payload);
|
|
if (data?.id) {
|
|
setPostId(data.id);
|
|
localStorage.setItem('voxblog_post_id', data.id);
|
|
}
|
|
if (!silent) {
|
|
setToast({ open: true, message: 'Post saved', severity: 'success' });
|
|
}
|
|
return data;
|
|
}, [postId, draft, meta, postStatus, promptText, genImageKeys, referenceImageKeys, generatedDraft, imagePlaceholders, generationSources]);
|
|
|
|
const deletePost = async () => {
|
|
if (!postId) return;
|
|
await deletePostApi(postId);
|
|
localStorage.removeItem('voxblog_post_id');
|
|
};
|
|
|
|
const publishToGhost = async (status: GhostPostStatus) => {
|
|
const tags = meta.tagsText.split(',').map(t => t.trim()).filter(Boolean);
|
|
await ghostPublishApi({
|
|
title: meta.title || 'Untitled',
|
|
html: draft,
|
|
feature_image: meta.featureImage || null,
|
|
tags,
|
|
status,
|
|
authors: selectedAuthor ? [selectedAuthor] : undefined,
|
|
});
|
|
|
|
// Update post status after successful Ghost publish
|
|
const newStatus = status === 'published' ? 'published' : 'ready_for_publish';
|
|
setPostStatus(newStatus);
|
|
|
|
// Save updated status to backend
|
|
await savePost({ status: newStatus });
|
|
|
|
setToast({ open: true, message: status === 'published' ? 'Published to Ghost' : 'Draft saved to Ghost', severity: 'success' });
|
|
};
|
|
|
|
const refreshPreview = async () => {
|
|
setPreviewLoading(true);
|
|
setPreviewError(null);
|
|
try {
|
|
const data = await ghostPreviewApi({ html: draft, feature_image: meta.featureImage || undefined });
|
|
setPreviewHtml(data.html || '');
|
|
} catch (e: any) {
|
|
setPreviewError(e?.message || 'Failed to generate preview');
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (activeStep === 5) refreshPreview();
|
|
}, [activeStep]);
|
|
|
|
// Auto-save with debouncing (silent save)
|
|
const triggerAutoSave = useCallback((delay = 500) => {
|
|
if (autoSaveTimeoutRef.current) {
|
|
clearTimeout(autoSaveTimeoutRef.current);
|
|
}
|
|
autoSaveTimeoutRef.current = setTimeout(() => {
|
|
savePost({}, true).catch(err => {
|
|
console.error('Auto-save failed:', err);
|
|
});
|
|
}, delay);
|
|
}, [savePost]);
|
|
|
|
// Immediate auto-save (no debounce, silent)
|
|
const triggerImmediateAutoSave = useCallback(async () => {
|
|
try {
|
|
await savePost({}, true);
|
|
} catch (err) {
|
|
console.error('Immediate auto-save failed:', err);
|
|
}
|
|
}, [savePost]);
|
|
|
|
const toggleGenImage = useCallback((key: string) => {
|
|
setGenImageKeys(prev => {
|
|
const newKeys = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key];
|
|
// Trigger immediate auto-save after state update
|
|
setTimeout(() => triggerImmediateAutoSave(), 0);
|
|
return newKeys;
|
|
});
|
|
}, [triggerImmediateAutoSave]);
|
|
|
|
const toggleReferenceImage = useCallback((key: string) => {
|
|
setReferenceImageKeys(prev => {
|
|
const newKeys = prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key];
|
|
// Trigger immediate auto-save after state update
|
|
setTimeout(() => triggerImmediateAutoSave(), 0);
|
|
return newKeys;
|
|
});
|
|
}, [triggerImmediateAutoSave]);
|
|
|
|
// Cleanup timeout on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
if (autoSaveTimeoutRef.current) {
|
|
clearTimeout(autoSaveTimeoutRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return {
|
|
// state
|
|
postId,
|
|
draft,
|
|
meta,
|
|
postClips,
|
|
postStatus,
|
|
promptText,
|
|
toast,
|
|
activeStep,
|
|
previewHtml,
|
|
previewLoading,
|
|
previewError,
|
|
genImageKeys,
|
|
referenceImageKeys,
|
|
generatedDraft,
|
|
imagePlaceholders,
|
|
generationSources,
|
|
isGenerating,
|
|
streamingContent,
|
|
tokenCount,
|
|
generationError,
|
|
selectedAuthor,
|
|
// setters
|
|
setDraft,
|
|
setMeta,
|
|
setPostClips,
|
|
setPostStatus,
|
|
setPromptText,
|
|
setToast,
|
|
setActiveStep,
|
|
setGeneratedDraft,
|
|
setImagePlaceholders,
|
|
setGenerationSources,
|
|
setIsGenerating,
|
|
setStreamingContent,
|
|
setTokenCount,
|
|
setGenerationError,
|
|
setSelectedAuthor,
|
|
// actions
|
|
savePost,
|
|
deletePost,
|
|
publishToGhost,
|
|
refreshPreview,
|
|
toggleGenImage,
|
|
toggleReferenceImage,
|
|
triggerAutoSave,
|
|
triggerImmediateAutoSave,
|
|
} as const;
|
|
}
|