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

View File

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

View File

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

View File

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

View File

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

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