feat: implement auto-save functionality for post editor with debouncing

This commit is contained in:
Ender 2025-10-25 00:22:06 +02:00
parent 3fee0d1acb
commit 5685f03b7e
6 changed files with 90 additions and 11 deletions

View File

@ -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>
)} )}

View File

@ -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">

View File

@ -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 = () => {

View File

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

View File

@ -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 = () => {

View File

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