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