voxblog/apps/admin/src/hooks/usePostEditor.ts
Ender b8f0da4644
All checks were successful
Deploy to Production / deploy (push) Successful in 2m1s
feat: remove canonical URL field and add URL validation
- 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
2025-10-28 15:13:12 +01:00

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;
}