feat: implement auto-save functionality for post editor with debouncing
This commit is contained in:
parent
3fee0d1acb
commit
5685f03b7e
@ -46,6 +46,8 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
||||
publishToGhost,
|
||||
refreshPreview,
|
||||
toggleGenImage,
|
||||
triggerAutoSave,
|
||||
triggerImmediateAutoSave,
|
||||
} = usePostEditor(initialPostId);
|
||||
|
||||
// Keyboard shortcut: Ctrl/Cmd+S to save
|
||||
@ -128,6 +130,7 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
||||
onSetFeature={(url: string) => setMeta(m => ({ ...m, featureImage: url }))}
|
||||
selectedKeys={genImageKeys}
|
||||
onToggleSelect={toggleGenImage}
|
||||
onAudioRemoved={triggerImmediateAutoSave}
|
||||
/>
|
||||
</StepContainer>
|
||||
)}
|
||||
@ -169,13 +172,18 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack
|
||||
generatedDraft={generatedDraft}
|
||||
imagePlaceholders={imagePlaceholders}
|
||||
selectedImageKeys={genImageKeys}
|
||||
onAutoSave={triggerImmediateAutoSave}
|
||||
/>
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
{activeStep === 4 && (
|
||||
<StepContainer>
|
||||
<StepMetadata value={meta} onChange={setMeta} />
|
||||
<StepMetadata
|
||||
value={meta}
|
||||
onChange={setMeta}
|
||||
onAutoSave={triggerImmediateAutoSave}
|
||||
/>
|
||||
</StepContainer>
|
||||
)}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ export default function StepAssets({
|
||||
onSetFeature,
|
||||
selectedKeys,
|
||||
onToggleSelect,
|
||||
onAudioRemoved,
|
||||
}: {
|
||||
postId?: string | null;
|
||||
postClips: Clip[];
|
||||
@ -22,6 +23,7 @@ export default function StepAssets({
|
||||
onSetFeature: (url: string) => void;
|
||||
selectedKeys?: string[];
|
||||
onToggleSelect?: (key: string) => void;
|
||||
onAudioRemoved?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Box sx={{ display: 'grid', gap: 2 }}>
|
||||
@ -35,6 +37,7 @@ export default function StepAssets({
|
||||
postId={postId ?? undefined}
|
||||
onInsertAtCursor={onInsertAtCursor}
|
||||
initialClips={postClips}
|
||||
onAudioRemoved={onAudioRemoved}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
<CollapsibleSection title="Media Library">
|
||||
|
||||
@ -11,6 +11,7 @@ export default function StepEdit({
|
||||
generatedDraft,
|
||||
imagePlaceholders,
|
||||
selectedImageKeys,
|
||||
onAutoSave,
|
||||
}: {
|
||||
editorRef: ForwardedRef<RichEditorHandle> | any;
|
||||
draftHtml: string;
|
||||
@ -18,9 +19,11 @@ export default function StepEdit({
|
||||
generatedDraft?: string;
|
||||
imagePlaceholders?: string[];
|
||||
selectedImageKeys?: string[];
|
||||
onAutoSave?: () => void;
|
||||
}) {
|
||||
const [showImagePicker, setShowImagePicker] = useState(false);
|
||||
const [currentPlaceholder, setCurrentPlaceholder] = useState<string>('');
|
||||
|
||||
const loadGeneratedDraft = () => {
|
||||
if (!generatedDraft) return;
|
||||
if (draftHtml && draftHtml !== '<p></p>' && !confirm('Replace current content with generated draft?')) {
|
||||
@ -36,6 +39,10 @@ export default function StepEdit({
|
||||
const newHtml = draftHtml.replace(placeholderPattern, replacement);
|
||||
onChangeDraft(newHtml);
|
||||
setShowImagePicker(false);
|
||||
// Trigger auto-save after replacing placeholder
|
||||
if (onAutoSave) {
|
||||
setTimeout(() => onAutoSave(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const detectPlaceholders = () => {
|
||||
|
||||
@ -2,14 +2,30 @@ import { Box } from '@mui/material';
|
||||
import MetadataPanel, { type Metadata } from '../MetadataPanel';
|
||||
import StepHeader from './StepHeader';
|
||||
|
||||
export default function StepMetadata({ value, onChange }: { value: Metadata; onChange: (v: Metadata) => void }) {
|
||||
export default function StepMetadata({
|
||||
value,
|
||||
onChange,
|
||||
onAutoSave
|
||||
}: {
|
||||
value: Metadata;
|
||||
onChange: (v: Metadata) => void;
|
||||
onAutoSave?: () => void;
|
||||
}) {
|
||||
const handleChange = (v: Metadata) => {
|
||||
onChange(v);
|
||||
// Trigger auto-save after metadata change
|
||||
if (onAutoSave) {
|
||||
setTimeout(() => onAutoSave(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<StepHeader
|
||||
title="Metadata"
|
||||
description="Configure post metadata including tags, canonical URL, and feature image."
|
||||
/>
|
||||
<MetadataPanel value={value} onChange={onChange} />
|
||||
<MetadataPanel value={value} onChange={handleChange} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Box, Button, Stack, Typography, Paper } from '@mui/material';
|
||||
|
||||
export default function Recorder({ postId, initialClips, onInsertAtCursor, onTranscript }: { postId?: string; initialClips?: Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string }>; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void }) {
|
||||
export default function Recorder({ postId, initialClips, onInsertAtCursor, onTranscript, onAudioRemoved }: { postId?: string; initialClips?: Array<{ id: string; bucket: string; key: string; mime: string; transcript?: string }>; onInsertAtCursor?: (html: string) => void; onTranscript?: (t: string) => void; onAudioRemoved?: () => void }) {
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const mimeRef = useRef<string>('audio/webm');
|
||||
@ -200,6 +200,11 @@ export default function Recorder({ postId, initialClips, onInsertAtCursor, onTra
|
||||
if (item?.url) URL.revokeObjectURL(item.url);
|
||||
return arr;
|
||||
});
|
||||
|
||||
// Notify parent that audio was removed (triggers auto-save)
|
||||
if (onAudioRemoved) {
|
||||
setTimeout(() => onAudioRemoved(), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTranscriptsToDraft = () => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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';
|
||||
@ -18,6 +18,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
const [genImageKeys, setGenImageKeys] = useState<string[]>([]);
|
||||
const [generatedDraft, setGeneratedDraft] = useState<string>('');
|
||||
const [imagePlaceholders, setImagePlaceholders] = useState<string[]>([]);
|
||||
const autoSaveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const savedId = initialPostId || localStorage.getItem('voxblog_post_id');
|
||||
@ -49,7 +50,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
}
|
||||
}, [initialPostId]);
|
||||
|
||||
const savePost = async (overrides?: Partial<SavePostPayload>) => {
|
||||
const savePost = useCallback(async (overrides?: Partial<SavePostPayload>, silent = false) => {
|
||||
localStorage.setItem('voxblog_draft', draft);
|
||||
const payload: SavePostPayload = {
|
||||
id: postId ?? undefined,
|
||||
@ -70,9 +71,11 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
setPostId(data.id);
|
||||
localStorage.setItem('voxblog_post_id', data.id);
|
||||
}
|
||||
setToast({ open: true, message: 'Post saved', severity: 'success' });
|
||||
if (!silent) {
|
||||
setToast({ open: true, message: 'Post saved', severity: 'success' });
|
||||
}
|
||||
return data;
|
||||
};
|
||||
}, [postId, draft, meta, postStatus, promptText, genImageKeys, generatedDraft, imagePlaceholders]);
|
||||
|
||||
const deletePost = async () => {
|
||||
if (!postId) return;
|
||||
@ -118,9 +121,44 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
if (activeStep === 5) refreshPreview();
|
||||
}, [activeStep]);
|
||||
|
||||
const toggleGenImage = (key: string) => {
|
||||
setGenImageKeys(prev => prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]);
|
||||
};
|
||||
// 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]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// state
|
||||
@ -154,5 +192,7 @@ export function usePostEditor(initialPostId?: string | null) {
|
||||
publishToGhost,
|
||||
refreshPreview,
|
||||
toggleGenImage,
|
||||
triggerAutoSave,
|
||||
triggerImmediateAutoSave,
|
||||
} as const;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user