From 5685f03b7e73b81b20480bc7d2e9f0fe18972889 Mon Sep 17 00:00:00 2001 From: Ender Date: Sat, 25 Oct 2025 00:22:06 +0200 Subject: [PATCH] feat: implement auto-save functionality for post editor with debouncing --- apps/admin/src/components/EditorShell.tsx | 10 +++- .../admin/src/components/steps/StepAssets.tsx | 3 ++ apps/admin/src/components/steps/StepEdit.tsx | 7 +++ .../src/components/steps/StepMetadata.tsx | 20 ++++++- apps/admin/src/features/recorder/Recorder.tsx | 7 ++- apps/admin/src/hooks/usePostEditor.ts | 54 ++++++++++++++++--- 6 files changed, 90 insertions(+), 11 deletions(-) diff --git a/apps/admin/src/components/EditorShell.tsx b/apps/admin/src/components/EditorShell.tsx index 3447c6f..c54efbb 100644 --- a/apps/admin/src/components/EditorShell.tsx +++ b/apps/admin/src/components/EditorShell.tsx @@ -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} /> )} @@ -169,13 +172,18 @@ export default function EditorShell({ onLogout: _onLogout, initialPostId, onBack generatedDraft={generatedDraft} imagePlaceholders={imagePlaceholders} selectedImageKeys={genImageKeys} + onAutoSave={triggerImmediateAutoSave} /> )} {activeStep === 4 && ( - + )} diff --git a/apps/admin/src/components/steps/StepAssets.tsx b/apps/admin/src/components/steps/StepAssets.tsx index 940f0ab..8f5bdfb 100644 --- a/apps/admin/src/components/steps/StepAssets.tsx +++ b/apps/admin/src/components/steps/StepAssets.tsx @@ -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 ( @@ -35,6 +37,7 @@ export default function StepAssets({ postId={postId ?? undefined} onInsertAtCursor={onInsertAtCursor} initialClips={postClips} + onAudioRemoved={onAudioRemoved} /> diff --git a/apps/admin/src/components/steps/StepEdit.tsx b/apps/admin/src/components/steps/StepEdit.tsx index 1a3dba2..a317d5e 100644 --- a/apps/admin/src/components/steps/StepEdit.tsx +++ b/apps/admin/src/components/steps/StepEdit.tsx @@ -11,6 +11,7 @@ export default function StepEdit({ generatedDraft, imagePlaceholders, selectedImageKeys, + onAutoSave, }: { editorRef: ForwardedRef | 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(''); + const loadGeneratedDraft = () => { if (!generatedDraft) return; if (draftHtml && draftHtml !== '

' && !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 = () => { diff --git a/apps/admin/src/components/steps/StepMetadata.tsx b/apps/admin/src/components/steps/StepMetadata.tsx index 4318fef..61f2a94 100644 --- a/apps/admin/src/components/steps/StepMetadata.tsx +++ b/apps/admin/src/components/steps/StepMetadata.tsx @@ -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 ( - + ); } diff --git a/apps/admin/src/features/recorder/Recorder.tsx b/apps/admin/src/features/recorder/Recorder.tsx index ac9c9c7..02bcc34 100644 --- a/apps/admin/src/features/recorder/Recorder.tsx +++ b/apps/admin/src/features/recorder/Recorder.tsx @@ -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(null); const chunksRef = useRef([]); const mimeRef = useRef('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 = () => { diff --git a/apps/admin/src/hooks/usePostEditor.ts b/apps/admin/src/hooks/usePostEditor.ts index f5c60c3..2ad8ace 100644 --- a/apps/admin/src/hooks/usePostEditor.ts +++ b/apps/admin/src/hooks/usePostEditor.ts @@ -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([]); const [generatedDraft, setGeneratedDraft] = useState(''); const [imagePlaceholders, setImagePlaceholders] = useState([]); + const autoSaveTimeoutRef = useRef | 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) => { + const savePost = useCallback(async (overrides?: Partial, 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; }